Let commandline registration group image versions
[joel/kofoto.git] / src / packages / kofoto / commandline / main.py
1 # Disable check for exception type except clause (see cmdInspectPath):
2 # pylint: disable-msg=W0702
3
4 """Main Kofoto commandline client code."""
5
6 __all__ = ["main"]
7
8 import codecs
9 import getopt
10 import os
11 import sys
12 import time
13
14 from kofoto.clientenvironment import ClientEnvironment, ClientEnvironmentError
15 from kofoto.clientutils import \
16     DIRECTORIES_TO_IGNORE, \
17     expanduser, \
18     get_file_encoding, \
19     group_image_versions, \
20     walk_files
21 from kofoto.albumtype import AlbumType
22 from kofoto.config import DEFAULT_CONFIGFILE_LOCATION
23 from kofoto.imageversiontype import ImageVersionType
24 from kofoto.search import \
25     BadTokenError, ParseError, Parser, UnterminatedStringError
26 from kofoto.shelf import \
27     computeImageHash, \
28     makeValidTag
29 from kofoto.shelfexceptions import \
30     AlbumDoesNotExistError, \
31     AlbumExistsError, \
32     BadAlbumTagError, \
33     BadCategoryTagError, \
34     CategoriesAlreadyConnectedError, \
35     CategoryDoesNotExistError, \
36     CategoryExistsError, \
37     CategoryLoopError, \
38     CategoryPresentError, \
39     ExifImportError, \
40     ImageDoesNotExistError, \
41     ImageVersionDoesNotExistError, \
42     ImageVersionExistsError, \
43     MultipleImageVersionsAtOneLocationError, \
44     NotAnImageFileError, \
45     ObjectDoesNotExistError, \
46     ShelfLockedError, \
47     ShelfNotFoundError, \
48     UndeletableAlbumError, \
49     UnknownAlbumTypeError, \
50     UnknownImageVersionTypeError, \
51     UnsettableChildrenError, \
52     UnsupportedShelfError
53
54 ######################################################################
55 ### Constants.
56
57 PRINT_ALBUMS_INDENT = 4
58
59 ######################################################################
60 ### Exceptions.
61
62 class ArgumentError(Exception):
63     """Bad arguments to command."""
64     pass
65
66 ######################################################################
67 ### Help text data.
68
69 optionDefinitionList = [
70     ("    --configfile FILE",
71      "Use configuration file FILE instead of the default (%s)." % (
72          DEFAULT_CONFIGFILE_LOCATION)),
73     ("    --database FILE",
74      "Use the metadata database FILE instead of the default (specified in the"
75      " configuration file)."),
76     ("    --gencharenc ENCODING",
77      "Generate HTML pages with character encoding ENCODING instead of the"
78      " default (utf-8)."),
79     ("-h, --help",
80      "Display this help."),
81     ("    --identify-by-hash",
82      "Identify image versions by hash. This is the default."),
83     ("    --identify-by-path",
84      "Identify image versions by path."),
85     ("    --ids",
86      "Print ID numbers instead of locations."),
87     ("    --include-all",
88      "Include all image versions for images matching a search expression."),
89     ("    --include-important",
90      "Include all image versions with type \"important\" for images matching a"
91      " search expression."),
92     ("    --include-original",
93      "Include all image versions with type \"original\" for images matching a"
94      " search expression."),
95     ("    --include-other",
96      "Include all image versions with type \"other\" for images matching a"
97      " search expression."),
98     ("    --include-primary",
99      "Include all primary image versions for images matching a search"
100      " expression."),
101     ("    --no-act",
102      "Do everything which is supposed to be done, but don't commit any changes"
103      " to the database."),
104     ("-0, --null",
105      "Use null characters instead of newlines when printing image version"
106      " locations. This is mainly useful in combination with \"xargs"
107      " --null\"."),
108     ("    --position POSITION",
109      "Add/register to position POSITION. Default: last."),
110     ("-t, --type TYPE",
111      "Use album type TYPE when creating an album or output type TYPE when"
112      " generating output."),
113     ("-v, --verbose",
114       "Be verbose (and slower)."),
115     ("    --version",
116      "Print version to standard output."),
117     ]
118
119 albumAndImageCommandsDefinitionList = [
120     ("add-category CATEGORY OBJECT [OBJECT ...]",
121      "Add a category to the given objects."),
122     ("delete-attribute ATTRIBUTE OBJECT [OBJECT ...]",
123      "Delete an attribute from the given objects."),
124     ("get-attribute ATTRIBUTE OBJECT",
125      "Get an attribute's value for an object."),
126     ("get-attributes OBJECT",
127      "Get attributes for an object."),
128     ("get-categories OBJECT",
129      "Get categories for an object."),
130     ("remove-category CATEGORY OBJECT [OBJECT ...]",
131      "Remove a category from the given objects."),
132     ("search SEARCHEXPRESSION",
133      "Search for images matching an expression and print image version"
134      " locations on standard output (or image IDs if the --ids option is"
135      " given) separated by newlines (or by null characters if --null is"
136      " given). By default, only primary image versions are printed, but other"
137      " versions can be printed by supplying one or several of the options"
138      " --include-all, --include-important, --include-original and"
139      " --include-other. (If no --include-* option is supplied,"
140      " --include-primary is assumed.)"),
141     ("set-attribute ATTRIBUTE VALUE OBJECT [OBJECT ...]",
142      "Set ATTRIBUTE to VALUE for the given objects."),
143     ]
144
145 albumCommandsDefinitionList = [
146     ("add ALBUM OBJECT [OBJECT ...]",
147      "Add the given objects (albums and images) to the album ALBUM. (The"
148      " objects are placed last if a position is not specified with"
149      " --position.)"),
150     ("create-album TAG",
151      "Create an empty, unlinked album with tag TAG. If a type argument is not"
152      " given with -t/--type, an album of type \"plain\" will be created."),
153     ("destroy-album ALBUM [ALBUM ...]",
154      "Destroy the given albums permanently. All metadata is also destroyed,"
155      " but not the album's children."),
156     ("generate ROOTALBUM DIRECTORY [SUBALBUM ...]",
157      "Generate output for ROOTALBUM in the directory DIRECTORY. If subalbums"
158      " are given, only generate those albums, their descendants and their"
159      " immediate parents. Use -t/--type to use another output type than the"
160      " default."),
161     ("print-albums [ALBUM]",
162      "Print the album graph for ALBUM (default: root). If -v/--verbose is"
163      " given, also print images and attributes."),
164     ("register ALBUM PATH [PATH ...]",
165      "Register objects (i.e. directories and images) and add them to the album"
166      " ALBUM. (The objects are placed last.) Directories are recursively"
167      " scanned for other directories and images, which also will be"
168      " registered."),
169     ("remove ALBUM POSITION [POSITION ...]",
170      "Remove the objects at the given positions from ALBUM."),
171     ("rename-album OLDTAG NEWTAG",
172      "Rename album tag."),
173     ("sort-album ALBUM [ATTRIBUTE]",
174      "Sort the contents of ALBUM by an attribute (default: captured)."),
175     ]
176
177 imageCommandsDefinitionList = [
178     ("destroy-image IMAGE [IMAGE ...]",
179      "Destroy the given images permanently. All metadata and image versions"
180      " are also destroyed (but not the image files on disk)."),
181     ("get-imageversions IMAGE",
182      "Print image versions for an image. If -v/--verbose is given, print more"
183      " information."),
184     ]
185
186 imageversionCommandsDefinitionList = [
187     ("destroy-imageversion IMAGEVERSION [IMAGEVERSION ...]",
188      "Destroy the given image versions permanently. All metadata is also"
189      " destroyed (but not the image files on disk)."),
190     ("find-missing-imageversions",
191      "Find missing image versions and print them to standard output."),
192     ("find-modified-imageversions",
193      "Find modified image versions and print them to standard output."),
194     ("inspect-path PATH [PATH ...]",
195      "Traverse the given paths and print whether each found file is a"
196      " registered, modified, moved or unregistered image version or a"
197      " non-image."),
198     ("make-primary IMAGEVERSION [IMAGEVERSION ...]",
199      "Make an image version the primary version."),
200     ("reread-exif IMAGEVERSION [IMAGEVERSION ...]",
201      "Reread EXIF information for the given image versions."),
202     ("set-imageversion-comment VALUE IMAGEVERSION [IMAGEVERSION ...]",
203      "Set comment of the given image versions."),
204     ("set-imageversion-image IMAGE IMAGEVERSION [IMAGEVERSION ...]",
205      "Set the image to which the given image versions belong."),
206     ("set-imageversion-type IMAGEVERSIONTYPE IMAGEVERSION [IMAGEVERSION ...]",
207      "Set type of the given image versions."),
208     ("update-contents PATH [PATH ...]",
209      "Traverse the given paths recursively and remember the new contents"
210      " (checksum, width and height) of found image versions."),
211     ("update-locations PATH [PATH ...]",
212      "Traverse the given paths recursively and remember the new locations of"
213      " found image versions."),
214     ]
215
216 categoryCommandsDefinitionList = [
217     ("connect-category PARENTCATEGORY CHILDCATEGORY",
218      "Make a category a child of another category."),
219     ("disconnect-category PARENTCATEGORY CHILDCATEGORY",
220      "Remove parent-child realationship bewteen two categories."),
221     ("create-category TAG DESCRIPTION",
222      "Create category."),
223     ("destroy-category CATEGORY [CATEGORY ...]",
224      "Destroy category permanently."),
225     ("print-categories",
226      "Print category tree."),
227     ("rename-category OLDTAG NEWTAG",
228      "Rename category tag."),
229     ("set-category-description TAG DESCRIPTION",
230      "Set category description."),
231     ]
232
233 miscellaneousCommandsDefinitionList = [
234     ("clean-cache",
235      "Clean up the image cache (remove left-over generated images)."),
236     ("print-statistics",
237      "Print some statistics about the database."),
238     ]
239
240 parameterSemanticsDefinitionList = [
241     ("ALBUM",
242      "An integer ID or an album tag (see TAG)."),
243     ("ATTRIBUTE",
244      "An arbitrary attribute name."),
245     ("CATEGORY",
246      "A category tag (see TAG)."),
247     ("DESCRIPTION",
248      "An arbitrary string."),
249     ("ENCODING",
250      "An encoding parsable by Python, e.g. utf-8, latin1 or iso-8859-1."),
251     ("FILE",
252      "A path to a file."),
253     ("IMAGE",
254      "An integer ID or a path to an image version file. If it's a path to an"
255      " image version, its corresponding image is selected. If"
256      " --identify-by-path is given, the path is used for identifying the image"
257      " version; otherwise the file's content is used for identification."),
258     ("IMAGEVERSION",
259      "An integer ID or a path to an image version file. If --identify-by-path"
260      " is given, the path is used for identifying the image version; otherwise"
261      " the file's contents used for identification."),
262     ("IMAGEVERSIONTYPE",
263      "\"important\", \"original\" or \"other\"."),
264     ("OBJECT",
265      "An integer ID, an album tag (see TAG) or a path to an image version"
266      " file. If it's a path to an image version, its corresponding image is"
267      " selected. If --identify-by-path is given, the path is used for"
268      " identifying the image version; otherwise the file's contents used for"
269      " identification."),
270     ("PATH",
271      "A path to a directory or a file."),
272     ("POSITION",
273      "An integer specifying an index into an album's children. 0 is the first"
274      " position, 1 is the second, and so on."),
275     ("SEARCHEXPRESSION",
276      "A search expression."
277      " See http://kofoto.rosdahl.net/trac/wiki/SearchExpressions for more"
278      " information."),
279     ("TAG",
280      "A text string not containing space or @ characters and not consisting"
281      " solely of integers."),
282     ("TYPE",
283      "An album type (as listed below) or an HTML output type (for the moment,"
284      " only \"woolly\" is allowed)."),
285     ("VALUE",
286      "An arbitrary string."),
287     ]
288
289 albumTypesDefinitionList = [
290     ("orphans",
291      "All albums and images that don't exist in any plain album."),
292     ("plain",
293      "An ordinary container that holds albums and images."),
294     ("search",
295      "An album containing the albums and images that match a search string"
296      " (sorted by capture timestamp). The search string is read from the"
297      " album's \"query\" attribute."),
298     ]
299
300 ######################################################################
301 ### Helper functions.
302
303 def printDefinitionList(deflist, outfile, tindent, dindent, width):
304     """Print a properly indented and wrapped definition list.
305
306     A definition list is a list of two-tuples where the first element
307     of the tuple is a string containing some term and the second
308     element of the tuple is the textual description of the term.
309
310     Arguments:
311
312     deflist -- The definition list.
313     outfile -- The file to which the formatted definition list should
314                be written.
315     tindent -- An integer specifying the left margin of the terms.
316     dindent -- An integer specifying the left margin of the descriptions.
317     width   -- An integer specifying the right margin of the descriptions.
318     """
319
320     from textwrap import TextWrapper
321     wrapper = TextWrapper(width=width)
322     for term, definition in deflist:
323         wrapper.subsequent_indent = (tindent + dindent) * " "
324         if len(term) < dindent - 1:
325             wrapper.initial_indent = tindent * " "
326             textToWrap = "%s%s%s" % (
327                 term,
328                 (dindent - len(term)) * " ",
329                 definition)
330         else:
331             wrapper.initial_indent = wrapper.subsequent_indent
332             outfile.write("%s%s\n" % (tindent * " ", term))
333             textToWrap = definition
334         outfile.write("%s\n" % wrapper.fill(textToWrap))
335
336 def displayHelp():
337     """Print commandline usage help text to standard output."""
338     sys.stdout.write(
339         "Usage: kofoto [options] command [parameters]\n"
340         "\n"
341         "Options:\n"
342         "\n")
343     printDefinitionList(optionDefinitionList, sys.stdout, 4, 27, 79)
344     sys.stdout.write(
345         "\n"
346         "Commands:\n"
347         "\n"
348         "    For albums and images\n"
349         "    =====================\n")
350     printDefinitionList(
351         albumAndImageCommandsDefinitionList, sys.stdout, 4, 27, 79)
352     sys.stdout.write(
353         "\n"
354         "    For albums\n"
355         "    ==========\n")
356     printDefinitionList(albumCommandsDefinitionList, sys.stdout, 4, 27, 79)
357     sys.stdout.write(
358         "\n"
359         "    For images\n"
360         "    ==========\n")
361     printDefinitionList(imageCommandsDefinitionList, sys.stdout, 4, 27, 79)
362     sys.stdout.write(
363         "\n"
364         "    For image versions\n"
365         "    ==================\n")
366     printDefinitionList(
367         imageversionCommandsDefinitionList, sys.stdout, 4, 27, 79)
368     sys.stdout.write(
369         "\n"
370         "    For categories\n"
371         "    ==============\n")
372     printDefinitionList(categoryCommandsDefinitionList, sys.stdout, 4, 27, 79)
373     sys.stdout.write(
374         "\n"
375         "    Miscellaneous\n"
376         "    =============\n")
377     printDefinitionList(
378         miscellaneousCommandsDefinitionList, sys.stdout, 4, 27, 79)
379     sys.stdout.write(
380         "\n"
381         "Parameter semantics:\n"
382         "\n"
383         "    Parameter         Interpretation\n"
384         "    --------------------------------\n")
385     printDefinitionList(
386         parameterSemanticsDefinitionList, sys.stdout, 4, 18, 79)
387     sys.stdout.write(
388         "\n"
389         "Album types:\n"
390         "\n"
391         "    Type              Description\n"
392         "    -----------------------------\n")
393     printDefinitionList(
394         albumTypesDefinitionList, sys.stdout, 4, 18, 79)
395
396 def printOutput(infoString):
397     """Print an informational string to standard output."""
398     sys.stdout.write(infoString)
399     sys.stdout.flush()
400
401 def printNotice(noticeString):
402     """Print a notice to standard error."""
403     sys.stderr.write("Notice: " + noticeString)
404
405 def printError(errorString):
406     """Print an error to standard error."""
407     sys.stderr.write("Error: " + errorString)
408
409 def printErrorAndExit(errorString):
410     """Print an error and exit the program with and error code."""
411     printError(errorString)
412     sys.exit(1)
413
414 def sloppyGetAlbum(env, idOrTag):
415     """Get an album by ID number or tag string."""
416     try:
417         return env.shelf.getAlbum(int(idOrTag))
418     except ValueError:
419         return env.shelf.getAlbumByTag(idOrTag)
420
421 def sloppyGetImage(env, idOrLocation):
422     """Get an image by ID number or location."""
423     try:
424         return env.shelf.getImage(int(idOrLocation))
425     except ValueError:
426         try:
427             if env.identifyByPath:
428                 imageversion = env.shelf.getImageVersionByLocation(
429                     idOrLocation)
430             else:
431                 imageversion = env.shelf.getImageVersionByHash(
432                     computeImageHash(idOrLocation))
433             return imageversion.getImage()
434         except ImageVersionDoesNotExistError:
435             raise ImageDoesNotExistError(idOrLocation)
436
437 def sloppyGetImageVersion(env, idOrLocation):
438     """Get an image version by ID number or location."""
439     try:
440         return env.shelf.getImageVersion(int(idOrLocation))
441     except ValueError:
442         if env.identifyByPath:
443             return env.shelf.getImageVersionByLocation(idOrLocation)
444         else:
445             return env.shelf.getImageVersionByHash(
446                 computeImageHash(idOrLocation))
447
448 def sloppyGetObject(env, idOrTagOrLocation):
449     """Get an object version by ID number or location."""
450     try:
451         return sloppyGetAlbum(env, idOrTagOrLocation)
452     except AlbumDoesNotExistError:
453         try:
454             return sloppyGetImage(env, idOrTagOrLocation)
455         except (ImageDoesNotExistError, ImageVersionDoesNotExistError), x:
456             raise ObjectDoesNotExistError(x)
457
458 def parseAlbumType(atype):
459     """Parse an album type.
460
461     This function takes a string representing an album type and
462     returns an alternative from kofoto.shelf.AlbumType. If the album
463     type does not exist, UnknownAlbumTypeError is raised.
464     """
465     try:
466         return {
467             u"plain": AlbumType.Plain,
468             u"orphans": AlbumType.Orphans,
469             u"search": AlbumType.Search,
470             }[atype]
471     except KeyError:
472         raise UnknownAlbumTypeError(atype)
473
474 def parseImageVersionType(ivtype):
475     """Parse an image version type.
476
477     This function takes a string representing an image version type
478     and returns an alternative from kofoto.shelf.ImageVersionType. If
479     the image version type does not exist,
480     UnknownImageVersionTypeError is raised. """
481     try:
482         return {
483             u"important": ImageVersionType.Important,
484             u"original": ImageVersionType.Original,
485             u"other": ImageVersionType.Other,
486             }[ivtype]
487     except KeyError:
488         raise UnknownImageVersionTypeError(ivtype)
489
490 ######################################################################
491 ### Helper classes.
492
493 class CommandlineClientEnvironment(ClientEnvironment):
494     """A class encapsulating the environment useful to a command."""
495
496     def __init__(self):
497         ClientEnvironment.__init__(self)
498         # Defaults:
499         self.identifyByPath = False
500         self.includeAll = False
501         self.includeImportant = False
502         self.includeOriginal = False
503         self.includeOther = False
504         self.includePrimary = False
505         self.noAct = False
506         self.useNullCharacters = False
507         self.position = -1
508         self.printIDs = False
509         self.type = None
510         self.verbose = False
511
512
513     def _writeInfo(self, infoString):
514         sys.stdout.write(infoString)
515         sys.stdout.flush()
516
517 ######################################################################
518 ### Commands.
519
520 def cmdAdd(env, args):
521     """Handler for the add command."""
522     if len(args) < 2:
523         raise ArgumentError
524     destalbum = sloppyGetAlbum(env, args[0])
525     objects = [sloppyGetObject(env, x) for x in args[1:]]
526     addHelper(env, destalbum, objects)
527
528
529 def addHelper(env, destalbum, objects):
530     """Helper function for cmdAdd."""
531     oldchildren = list(destalbum.getChildren())
532     if env.position == -1:
533         pos = len(oldchildren)
534     else:
535         pos = env.position
536     destalbum.setChildren(oldchildren[:pos] + objects + oldchildren[pos:])
537
538
539 def cmdAddCategory(env, args):
540     """Handler for the add-category command."""
541     if len(args) < 2:
542         raise ArgumentError
543     category = env.shelf.getCategoryByTag(args[0])
544     for arg in args[1:]:
545         sloppyGetObject(env, arg).addCategory(category)
546
547
548 def cmdCleanCache(env, args):
549     """Handler for the clean-cache command."""
550     if env.noAct:
551         raise ArgumentError
552     if len(args) != 0:
553         raise ArgumentError
554     env.imageCache.cleanup()
555
556
557 def cmdConnectCategory(env, args):
558     """Handler for the connect-category command."""
559     if len(args) != 2:
560         raise ArgumentError
561     parent = env.shelf.getCategoryByTag(args[0])
562     child = env.shelf.getCategoryByTag(args[1])
563     parent.connectChild(child)
564
565
566 def cmdCreateAlbum(env, args):
567     """Handler for the create-album command."""
568     if len(args) != 1:
569         raise ArgumentError
570     if env.type:
571         atype = env.type
572     else:
573         atype = u"plain"
574     env.shelf.createAlbum(args[0], parseAlbumType(atype))
575
576
577 def cmdCreateCategory(env, args):
578     """Handler for the create-category command."""
579     if len(args) != 2:
580         raise ArgumentError
581     env.shelf.createCategory(args[0], args[1])
582
583
584 def cmdDeleteAttribute(env, args):
585     """Handler for the delete-attribute command."""
586     if len(args) < 2:
587         raise ArgumentError
588     attr = args[0]
589     for arg in args[1:]:
590         sloppyGetObject(env, arg).deleteAttribute(attr)
591
592
593 def cmdDestroyAlbum(env, args):
594     """Handler for the destroy-album command."""
595     if len(args) == 0:
596         raise ArgumentError
597     for arg in args:
598         env.shelf.deleteAlbum(sloppyGetAlbum(env, arg).getId())
599
600
601 def cmdDestroyCategory(env, args):
602     """Handler for the destroy-category command."""
603     if len(args) == 0:
604         raise ArgumentError
605     for arg in args:
606         env.shelf.deleteCategory(env.shelf.getCategoryByTag(arg).getId())
607
608
609 def cmdDestroyImage(env, args):
610     """Handler for the destroy-image command."""
611     if len(args) == 0:
612         raise ArgumentError
613     for arg in args:
614         env.shelf.deleteImage(sloppyGetImage(env, arg).getId())
615
616
617 def cmdDestroyImageVersion(env, args):
618     """Handler for the destroy-imageversion command."""
619     if len(args) == 0:
620         raise ArgumentError
621     for arg in args:
622         env.shelf.deleteImageVersion(sloppyGetImageVersion(env, arg).getId())
623
624
625 def cmdDisconnectCategory(env, args):
626     """Handler for the disconnect-category command."""
627     if len(args) != 2:
628         raise ArgumentError
629     parent = env.shelf.getCategoryByTag(args[0])
630     child = env.shelf.getCategoryByTag(args[1])
631     parent.disconnectChild(child)
632
633
634 def cmdFindMissingImageVersions(env, args):
635     """Handler for the find-missing-imageversions command."""
636     if len(args) != 0:
637         raise ArgumentError
638     paths = []
639     for iv in env.shelf.getAllImageVersions():
640         location = iv.getLocation()
641         if env.verbose:
642             env.out("Checking %s ...\n" % location)
643         if not os.path.exists(location):
644             paths.append(location)
645     for path in paths:
646         env.out("%s\n" % path)
647
648
649 def cmdFindModifiedImageVersions(env, args):
650     """Handler for the find-modified-imageversions command."""
651     if len(args) != 0:
652         raise ArgumentError
653     paths = []
654     for iv in env.shelf.getAllImageVersions():
655         location = iv.getLocation()
656         if env.verbose:
657             env.out("Checking %s ...\n" % location)
658         try:
659             realId = computeImageHash(location)
660             storedId = iv.getHash()
661             if realId != storedId:
662                 paths.append(location)
663         except IOError:
664             pass
665     for path in paths:
666         env.out("%s\n" % path)
667
668
669 def cmdGenerate(env, args):
670     """Handler for the generate command."""
671     if len(args) < 2:
672         raise ArgumentError
673     root = sloppyGetAlbum(env, args[0])
674     dest = args[1]
675     subalbums = [sloppyGetAlbum(env, x) for x in args[2:]]
676     if env.type:
677         otype = env.type
678     else:
679         otype = u"woolly"
680     import kofoto.generate
681     try:
682         generator = kofoto.generate.Generator(otype, env)
683         generator.generate(root, subalbums, dest, env.gencharenc)
684     except kofoto.generate.OutputTypeError, x:
685         env.errexit("No such output module: %s\n" % x)
686
687
688 def cmdGetAttribute(env, args):
689     """Handler for the get-attribute command."""
690     if len(args) != 2:
691         raise ArgumentError
692     obj = sloppyGetObject(env, args[1])
693     value = obj.getAttribute(args[0])
694     if value:
695         env.out(value + "\n")
696
697
698 def cmdGetAttributes(env, args):
699     """Handler for the get-attributes command."""
700     if len(args) != 1:
701         raise ArgumentError
702     obj = sloppyGetObject(env, args[0])
703     for name in obj.getAttributeNames():
704         env.out("%s: %s\n" % (name, obj.getAttribute(name)))
705
706
707 def cmdGetCategories(env, args):
708     """Handler for the get-categories command."""
709     if len(args) != 1:
710         raise ArgumentError
711     obj = sloppyGetObject(env, args[0])
712     for category in obj.getCategories():
713         env.out("%s (%s) <%s>\n" % (
714             category.getDescription(),
715             category.getTag(),
716             category.getId()))
717
718
719 def cmdGetImageVersions(env, args):
720     """Handler for the get-imageversions command."""
721     if len(args) != 1:
722         raise ArgumentError
723     image = sloppyGetImage(env, args[0])
724     for iv in image.getImageVersions():
725         if env.printIDs:
726             env.out("%s\n" % iv.getId())
727         elif env.verbose:
728             env.out("%s\n  %s%s\n" % (
729                 iv.getLocation(),
730                 iv.isPrimary() and "Primary, " or "",
731                 iv.getType()))
732             if iv.getComment():
733                 env.out("  %s\n" % iv.getComment())
734         else:
735             env.out("%s\n" % iv.getLocation())
736
737
738 def cmdInspectPath(env, args):
739     """Handler for the inspect-path command."""
740     if len(args) < 1:
741         raise ArgumentError
742     import Image as PILImage
743     for filepath in walk_files(args):
744         try:
745             imageversion = env.shelf.getImageVersionByHash(
746                 computeImageHash(filepath))
747             if imageversion.getLocation() == os.path.realpath(filepath):
748                 env.out("[Registered]   %s\n" % filepath)
749             else:
750                 env.out("[Moved]        %s\n" % filepath)
751         except ImageVersionDoesNotExistError:
752             try:
753                 imageversion = env.shelf.getImageVersionByLocation(filepath)
754                 env.out("[Modified]     %s\n" % filepath)
755             except MultipleImageVersionsAtOneLocationError:
756                 env.out("[Multiple]     %s\n" % filepath)
757             except ImageVersionDoesNotExistError:
758                 try:
759                     PILImage.open(filepath)
760                     env.out("[Unregistered] %s\n" % filepath)
761 #                except IOError:
762                 except: # Work-around for buggy PIL.
763                     env.out("[Non-image]    %s\n" % filepath)
764
765
766 def cmdMakePrimary(env, args):
767     """Handler for the make-primary command."""
768     if len(args) == 0:
769         raise ArgumentError
770     for iv in args:
771         sloppyGetImageVersion(env, iv).makePrimary()
772
773
774 def cmdPrintAlbums(env, args):
775     """Handler for the print-albums command."""
776     if len(args) > 0:
777         root = sloppyGetAlbum(env, args[0])
778     else:
779         root = env.shelf.getRootAlbum()
780     printAlbumsHelper(env, root, 0, 0, [])
781
782
783 def printAlbumsHelper(env, obj, position, level, visited):
784     """Helper function for cmdPrintAlbums."""
785     imgtmpl = "%(indent)s[I] {%(position)s} <%(id)s>\n"
786     imgvertmpl = "%(indent)s[V] %(location)s {%(primary)s%(type)s} <%(id)s>\n"
787     if env.verbose:
788         albtmpl = "%(indent)s[A] %(name)s {%(position)s} <%(id)s> (%(type)s)\n"
789     else:
790         albtmpl = "%(indent)s[A] %(name)s <%(id)s> (%(type)s)\n"
791     indentspaces = PRINT_ALBUMS_INDENT * " "
792     if obj.isAlbum():
793         tag = obj.getTag()
794         env.out(albtmpl % {
795             "indent": level * indentspaces,
796             "name": tag,
797             "position": position,
798             "id": obj.getId(),
799             "type": obj.getType(),
800             })
801         if tag in visited:
802             env.out("%s[...]\n" % ((level + 1) * indentspaces))
803         else:
804             pos = 0
805             if env.verbose:
806                 children = obj.getChildren()
807             else:
808                 children = obj.getAlbumChildren()
809             for child in children:
810                 printAlbumsHelper(
811                     env,
812                     child,
813                     pos,
814                     level + 1,
815                     visited + [tag])
816                 pos += 1
817     else:
818         env.out(imgtmpl % {
819             "indent": level * indentspaces,
820             "position": position,
821             "id": obj.getId(),
822             })
823         for iv in obj.getImageVersions():
824             env.out(imgvertmpl % {
825                 "indent": (level + 1) * indentspaces,
826                 "id": iv.getId(),
827                 "location": iv.getLocation(),
828                 "primary": iv.isPrimary() and "Primary " or "",
829                 "type": iv.getType(),
830                 })
831     if env.verbose:
832         attrtmpl = "%(indent)s%(key)s: %(value)s\n"
833         names = obj.getAttributeNames()
834         for name in names:
835             env.out(attrtmpl % {
836                 "indent": (level + 1) * indentspaces,
837                 "key": name,
838                 "value": obj.getAttribute(name),
839                 })
840
841
842 def cmdPrintCategories(env, args):
843     """Handler for the print-categories command."""
844     if len(args) != 0:
845         raise ArgumentError
846     for category in env.shelf.getRootCategories():
847         printCategoriesHelper(env, category, 0)
848
849
850 def printCategoriesHelper(env, category, level):
851     """Helper function for cmdPrintCategories."""
852     indentspaces = PRINT_ALBUMS_INDENT * " "
853     env.out("%s%s (%s) <%s>\n" % (
854         level * indentspaces,
855         category.getDescription(),
856         category.getTag(),
857         category.getId()))
858     for child in category.getChildren():
859         printCategoriesHelper(env, child, level + 1)
860
861
862 def cmdPrintStatistics(env, dummy):
863     """Handler for the print-statistics command."""
864     stats = env.shelf.getStatistics()
865     env.out("Number of albums: %d\n" % stats["nalbums"])
866     env.out("Number of categories: %d\n" % stats["ncategories"])
867     env.out("Number of images: %d\n" % stats["nimages"])
868     env.out("Number of image versions: %d\n" % stats["nimageversions"])
869
870
871 def cmdRegister(env, args):
872     """Handler for the register command."""
873     if len(args) < 2:
874         raise ArgumentError
875     destalbum = sloppyGetAlbum(env, args[0])
876     registrationTimeString = unicode(time.strftime("%Y-%m-%d %H:%M:%S"))
877     registerHelper(
878         env,
879         destalbum,
880         registrationTimeString,
881         args[1:])
882
883
884 def registerHelper(env, destalbum, registrationTimeString, paths):
885     """Helper function for cmdRegister."""
886     paths.sort()
887     newchildren = []
888     filepaths = []
889     for path in paths:
890         if env.verbose:
891             env.out("Processing %s ...\n" % path)
892         if os.path.isdir(path):
893             tag = os.path.basename(path)
894             if tag in DIRECTORIES_TO_IGNORE:
895                 if env.verbose:
896                     env.out("Ignoring.\n")
897                 continue
898             tag = makeValidTag(tag)
899             while True:
900                 try:
901                     album = env.shelf.createAlbum(tag)
902                     break
903                 except AlbumExistsError:
904                     tag += "_"
905             newchildren.append(album)
906             env.out("Registered directory %s as an album with tag %s\n" % (
907                 path,
908                 tag))
909             registerHelper(
910                 env,
911                 album,
912                 registrationTimeString,
913                 [os.path.join(path, x) for x in os.listdir(path)])
914         elif os.path.isfile(path):
915             filepaths.append(path)
916         else:
917             env.err("No such file or directory (ignored): %s\n" % path)
918     for vpaths in group_image_versions(filepaths):
919         image = env.shelf.createImage()
920         validVersions = 0
921         for (i, vpath) in enumerate(vpaths):
922             if i == 0:
923                 versiontype = ImageVersionType.Original
924             else:
925                 versiontype = ImageVersionType.Other
926             try:
927                 env.shelf.createImageVersion(image, vpath, versiontype)
928                 image.setAttribute(u"registered", registrationTimeString)
929                 if env.verbose:
930                     if i == 0:
931                         tstr = "original image"
932                     else:
933                         tstr = "version of above original"
934                     env.out("Registered %s: %s\n" % (tstr, vpath))
935             except NotAnImageFileError, x:
936                 env.out("Ignoring non-image file: %s\n" % vpath)
937             except ImageVersionExistsError, x:
938                 env.err("Ignoring already registered image: %s\n" % vpath)
939             else:
940                 validVersions += 1
941         if validVersions > 0:
942             newchildren.append(image)
943         else:
944             env.shelf.deleteImage(image.getId())
945     addHelper(env, destalbum, newchildren)
946
947
948 def cmdRemove(env, args):
949     """Handler for the remove command."""
950     if len(args) < 2:
951         raise ArgumentError
952     album = sloppyGetAlbum(env, args[0])
953     positions = []
954     for pos in args[1:]:
955         try:
956             positions.append(int(pos))
957         except ValueError:
958             env.errexit("Bad position: %s.\n" % pos)
959     positions.sort(reverse=True)
960     children = list(album.getChildren())
961     if not (0 <= positions[0] < len(children)):
962         env.errexit("Bad position: %d.\n" % positions[0])
963     for pos in positions:
964         del children[pos]
965     album.setChildren(children)
966
967
968 def cmdRemoveCategory(env, args):
969     """Handler for the remove-category command."""
970     if len(args) < 2:
971         raise ArgumentError
972     category = env.shelf.getCategoryByTag(args[0])
973     for arg in args[1:]:
974         sloppyGetObject(env, arg).removeCategory(category)
975
976
977 def cmdRenameAlbum(env, args):
978     """Handler for the rename-album command."""
979     if len(args) != 2:
980         raise ArgumentError
981     sloppyGetAlbum(env, args[0]).setTag(args[1])
982
983
984 def cmdRenameCategory(env, args):
985     """Handler for the rename-category command."""
986     if len(args) != 2:
987         raise ArgumentError
988     env.shelf.getCategoryByTag(args[0]).setTag(args[1])
989
990
991 def cmdRereadExif(env, args):
992     """Handler for the reread-exif command."""
993     if len(args) == 0:
994         raise ArgumentError
995     for iv in args:
996         sloppyGetImageVersion(env, iv).importExifTags(True)
997
998
999 def cmdSearch(env, args):
1000     """Handler for the search command."""
1001     if len(args) != 1:
1002         raise ArgumentError
1003     parser = Parser(env.shelf)
1004     objects = env.shelf.search(parser.parse(args[0]))
1005     objects = [x for x in objects if not x.isAlbum()]
1006     ivs = []
1007     for o in objects:
1008         for iv in o.getImageVersions():
1009             ivs.append(iv)
1010     if env.printIDs:
1011         ids = set()
1012         for iv in ivs:
1013             ids.add(iv.getImage().getId())
1014         output = [str(x) for x in ids]
1015     else:
1016         output = []
1017         for iv in ivs:
1018             t = iv.getType()
1019             if (env.includeAll or
1020                 (env.includeImportant and t == ImageVersionType.Important) or
1021                 (env.includeOriginal and t == ImageVersionType.Original) or
1022                 (env.includeOther and t == ImageVersionType.Other) or
1023                 (env.includePrimary and iv.isPrimary())):
1024                 output.append(iv.getLocation())
1025         output.sort()
1026     if output:
1027         if env.useNullCharacters:
1028             terminator = u"\0"
1029         else:
1030             terminator = u"\n"
1031         env.out(u"%s%s" % (terminator.join(output), terminator))
1032
1033
1034 def cmdSetAttribute(env, args):
1035     """Handler for the set-attribute command."""
1036     if len(args) < 3:
1037         raise ArgumentError
1038     attr = args[0]
1039     value = args[1]
1040     for arg in args[2:]:
1041         sloppyGetObject(env, arg).setAttribute(attr, value)
1042
1043
1044 def cmdSetCategoryDescription(env, args):
1045     """Handler for the set-category-description command."""
1046     if len(args) != 2:
1047         raise ArgumentError
1048     env.shelf.getCategoryByTag(args[0]).setDescription(args[1])
1049
1050
1051 def cmdSetImageVersionComment(env, args):
1052     """Handler for the set-imageversion-comment command."""
1053     if len(args) < 2:
1054         raise ArgumentError
1055     comment = args[0]
1056     for iv in args[1:]:
1057         sloppyGetImageVersion(env, iv).setComment(comment)
1058
1059
1060 def cmdSetImageVersionImage(env, args):
1061     """Handler for the set-imageversion-image command."""
1062     if len(args) < 2:
1063         raise ArgumentError
1064     image = sloppyGetImage(env, args[0])
1065     for iv in args[1:]:
1066         sloppyGetImageVersion(env, iv).setImage(image)
1067
1068
1069 def cmdSetImageVersionType(env, args):
1070     """Handler for the set-imageversion-type command."""
1071     if len(args) < 2:
1072         raise ArgumentError
1073     ivtype = parseImageVersionType(args[0])
1074     for iv in args[1:]:
1075         sloppyGetImageVersion(env, iv).setType(ivtype)
1076
1077
1078 def cmdSortAlbum(env, args):
1079     """Handler for the sort-album command."""
1080     if not 1 <= len(args) <= 2:
1081         raise ArgumentError
1082     if len(args) == 2:
1083         attr = args[1]
1084     else:
1085         attr = u"captured"
1086     album = sloppyGetAlbum(env, args[0])
1087     children = sorted(album.getChildren(), key=lambda x: x.getAttribute(attr))
1088     album.setChildren(children)
1089
1090
1091 def cmdUpdateContents(env, args):
1092     """Handler for the update-contents command."""
1093     if len(args) < 1:
1094         raise ArgumentError
1095     for filepath in walk_files(args):
1096         try:
1097             imageversion = env.shelf.getImageVersionByLocation(filepath)
1098             oldhash = imageversion.getHash()
1099             imageversion.contentChanged()
1100             if imageversion.getHash() != oldhash:
1101                 env.out("New checksum: %s\n" % filepath)
1102             else:
1103                 if env.verbose:
1104                     env.out(
1105                         "Same checksum as before: %s\n" % filepath)
1106         except ImageVersionDoesNotExistError:
1107             if env.verbose:
1108                 env.out("Unregistered image/file: %s\n" % filepath)
1109         except MultipleImageVersionsAtOneLocationError:
1110             env.errexit(
1111                 "Multiple known image versions at this location: %s\n" % (
1112                 filepath))
1113
1114
1115 def cmdUpdateLocations(env, args):
1116     """Handler for the update-locations command."""
1117     if len(args) < 1:
1118         raise ArgumentError
1119     for filepath in walk_files(args):
1120         try:
1121             imageversion = env.shelf.getImageVersionByHash(
1122                 computeImageHash(filepath))
1123             oldlocation = imageversion.getLocation()
1124             if oldlocation != os.path.realpath(filepath):
1125                 imageversion.locationChanged(filepath)
1126                 env.out("New location: %s --> %s\n" % (
1127                     oldlocation,
1128                     imageversion.getLocation()))
1129             else:
1130                 if env.verbose:
1131                     env.out(
1132                         "Same location as before: %s\n" % filepath)
1133         except IOError, x:
1134             if env.verbose:
1135                 env.out("Failed to read: %s (%s)\n" % (
1136                     filepath,
1137                     x))
1138         except ImageVersionDoesNotExistError:
1139             if env.verbose:
1140                 env.out("Unregistered image/file: %s\n" % filepath)
1141
1142
1143 commandTable = {
1144     "add": cmdAdd,
1145     "add-category": cmdAddCategory,
1146     "clean-cache": cmdCleanCache,
1147     "connect-category": cmdConnectCategory,
1148     "create-album": cmdCreateAlbum,
1149     "create-category": cmdCreateCategory,
1150     "delete-attribute": cmdDeleteAttribute,
1151     "destroy-album": cmdDestroyAlbum,
1152     "destroy-category": cmdDestroyCategory,
1153     "destroy-image": cmdDestroyImage,
1154     "destroy-imageversion": cmdDestroyImageVersion,
1155     "disconnect-category": cmdDisconnectCategory,
1156     "find-missing-imageversions": cmdFindMissingImageVersions,
1157     "find-modified-imageversions": cmdFindModifiedImageVersions,
1158     "generate": cmdGenerate,
1159     "get-attribute": cmdGetAttribute,
1160     "get-attributes": cmdGetAttributes,
1161     "get-categories": cmdGetCategories,
1162     "get-imageversions": cmdGetImageVersions,
1163     "inspect-path": cmdInspectPath,
1164     "make-primary": cmdMakePrimary,
1165     "print-albums": cmdPrintAlbums,
1166     "print-categories": cmdPrintCategories,
1167     "print-statistics": cmdPrintStatistics,
1168     "register": cmdRegister,
1169     "remove": cmdRemove,
1170     "remove-category": cmdRemoveCategory,
1171     "rename-album": cmdRenameAlbum,
1172     "rename-category": cmdRenameCategory,
1173     "reread-exif": cmdRereadExif,
1174     "search": cmdSearch,
1175     "set-attribute": cmdSetAttribute,
1176     "set-category-description": cmdSetCategoryDescription,
1177     "set-imageversion-comment": cmdSetImageVersionComment,
1178     "set-imageversion-image": cmdSetImageVersionImage,
1179     "set-imageversion-type": cmdSetImageVersionType,
1180     "sort-album": cmdSortAlbum,
1181     "update-contents": cmdUpdateContents,
1182     "update-locations": cmdUpdateLocations,
1183 }
1184
1185 ######################################################################
1186 ### Main
1187
1188 def main(argv):
1189     """Run the program.
1190
1191     Arguments:
1192
1193     argv -- A list of arguments.
1194     """
1195     env = CommandlineClientEnvironment()
1196
1197     argv = [x.decode(env.filesystemEncoding) for x in argv]
1198
1199     sys.stdin = codecs.getreader(get_file_encoding(sys.stdin))(sys.stdin)
1200     sys.stdout = codecs.getwriter(get_file_encoding(sys.stdout))(sys.stdout)
1201     sys.stderr = codecs.getwriter(get_file_encoding(sys.stderr))(sys.stderr)
1202
1203     try:
1204         optlist, args = getopt.gnu_getopt(
1205             argv[1:],
1206             "0ht:v",
1207             ["configfile=",
1208              "database=",
1209              "gencharenc=",
1210              "help",
1211              "identify-by-hash",
1212              "identify-by-path",
1213              "ids",
1214              "include-all",
1215              "include-important",
1216              "include-original",
1217              "include-other",
1218              "include-primary",
1219              "no-act",
1220              "null",
1221              "position=",
1222              "type=",
1223              "verbose",
1224              "version"])
1225     except getopt.GetoptError:
1226         printErrorAndExit("Unknown option. See \"kofoto --help\" for help.\n")
1227
1228     # Other defaults:
1229     shelfLocation = None
1230     configFileLocation = None
1231     genCharEnc = "utf-8"
1232
1233     for opt, optarg in optlist:
1234         if opt == "--configfile":
1235             configFileLocation = expanduser(optarg)
1236         elif opt == "--database":
1237             shelfLocation = optarg
1238         elif opt == "--gencharenc":
1239             genCharEnc = str(optarg)
1240         elif opt in ("-h", "--help"):
1241             displayHelp()
1242             sys.exit(0)
1243         elif opt == "--identify-by-hash":
1244             env.identifyByPath = False
1245         elif opt == "--identify-by-path":
1246             env.identifyByPath = True
1247         elif opt == "--ids":
1248             env.printIDs = True
1249         elif opt == "--include-all":
1250             env.includeAll = True
1251         elif opt == "--include-important":
1252             env.includeImportant = True
1253         elif opt == "--include-original":
1254             env.includeOriginal = True
1255         elif opt == "--include-other":
1256             env.includeOther = True
1257         elif opt == "--include-primary":
1258             env.includePrimary = True
1259         elif opt == "--no-act":
1260             printNotice(
1261                 "no-act: No changes will be commited to the database!\n")
1262             env.noAct = True
1263         elif opt in ("-0", "--null"):
1264             env.useNullCharacters = True
1265         elif opt == "--position":
1266             if optarg == "last":
1267                 env.position = -1
1268             else:
1269                 try:
1270                     env.position = int(optarg)
1271                 except ValueError:
1272                     printErrorAndExit("Invalid position: \"%s\"\n" % optarg)
1273         elif opt in ("-t", "--type"):
1274             env.type = optarg
1275         elif opt in ("-v", "--verbose"):
1276             env.verbose = True
1277         elif opt == "--version":
1278             sys.stdout.write("%s\n" % env.version)
1279             sys.exit(0)
1280
1281     if not (env.includeAll or env.includeImportant or env.includeOriginal
1282             or env.includeOther or env.includePrimary):
1283         env.includePrimary = True
1284
1285     if len(args) == 0:
1286         printErrorAndExit(
1287             "No command given. See \"kofoto --help\" for help.\n")
1288
1289     try:
1290         env.setup(configFileLocation, shelfLocation)
1291     except ClientEnvironmentError, e:
1292         printErrorAndExit(e[0])
1293
1294     if not commandTable.has_key(args[0]):
1295         printErrorAndExit(
1296             "Unknown command \"%s\". See \"kofoto --help\" for help.\n" % (
1297             args[0]))
1298
1299     try:
1300         if env.shelf.isUpgradable():
1301             printNotice(
1302                 "Upgrading %s to new database format...\n" % env.shelfLocation)
1303             if not env.shelf.tryUpgrade():
1304                 printErrorAndExit(
1305                     "Failed to upgrade metadata database format.\n")
1306         env.shelf.begin()
1307     except ShelfNotFoundError, x:
1308         printErrorAndExit("Could not open metadata database \"%s\".\n" % (
1309             env.shelfLocation))
1310     except ShelfLockedError, x:
1311         printErrorAndExit(
1312             "Could not open metadata database \"%s\".\n" % env.shelfLocation +
1313             "Another process is locking it.\n")
1314     except UnsupportedShelfError, filename:
1315         printErrorAndExit(
1316             "Could not read metadata database file %s (too new database"
1317             " format?).\n" % filename)
1318     try:
1319         env.gencharenc = genCharEnc
1320         env.out = printOutput
1321         env.err = printError
1322         env.errexit = printErrorAndExit
1323         env.thumbnailsizelimit = env.config.getcoordlist(
1324             "album generation", "thumbnail_size_limit")[0]
1325         env.defaultsizelimit = env.config.getcoordlist(
1326             "album generation", "default_image_size_limit")[0]
1327
1328         imgsizesval = env.config.getcoordlist(
1329             "album generation", "other_image_size_limits")
1330         imgsizesset = set(imgsizesval) # Get rid of duplicates.
1331         defaultlimit = env.config.getcoordlist(
1332             "album generation", "default_image_size_limit")[0]
1333         imgsizesset.add(defaultlimit)
1334         imgsizes = sorted(
1335             imgsizesset, cmp=lambda x, y: cmp(x[0] * x[1], y[0] * y[1]))
1336         env.imagesizelimits = imgsizes
1337
1338         commandTable[args[0]](env, args[1:])
1339         if env.noAct:
1340             env.shelf.rollback()
1341             printOutput(
1342                 "no-act: All changes to the database have been revoked!\n")
1343         else:
1344             env.shelf.commit()
1345         sys.exit(0)
1346     except ArgumentError:
1347         printErrorAndExit(
1348             "Bad arguments to command. See \"kofoto --help\" for help.\n")
1349     except UndeletableAlbumError, x:
1350         printError("Undeletable album: \"%s\".\n" % x.args[0])
1351     except BadAlbumTagError, x:
1352         printError("Bad album tag: \"%s\".\n" % x.args[0])
1353     except AlbumExistsError, x:
1354         printError("Album already exists: \"%s\".\n" % x.args[0])
1355     except ImageDoesNotExistError, x:
1356         printError("Image does not exist: \"%s\".\n" % x.args[0])
1357     except AlbumDoesNotExistError, x:
1358         printError("Album does not exist: \"%s\".\n" % x.args[0])
1359     except ObjectDoesNotExistError, x:
1360         printError("Object does not exist: \"%s\".\n" % x.args[0])
1361     except UnknownAlbumTypeError, x:
1362         printError("Unknown album type: \"%s\".\n" % x.args[0])
1363     except UnsettableChildrenError, x:
1364         printError(
1365             "Cannot modify children of \"%s\" (children are created"
1366             " virtually).\n" % x.args[0])
1367     except CategoryExistsError, x:
1368         printError("Category already exists: \"%s\".\n" % x.args[0])
1369     except CategoryDoesNotExistError, x:
1370         printError("Category does not exist: \"%s\".\n" % x.args[0])
1371     except BadCategoryTagError, x:
1372         printError("Bad category tag: %s.\n" % x.args[0])
1373     except CategoryPresentError, x:
1374         printError("Object %s is already associated with category %s.\n" % (
1375             x.args[0], x.args[1]))
1376     except CategoriesAlreadyConnectedError, x:
1377         printError("Categories %s and %s are already connected.\n" % (
1378             x.args[0], x.args[1]))
1379     except CategoryLoopError, x:
1380         printError(
1381             "Connecting %s to %s would make a loop in the categories.\n" % (
1382             x.args[0], x.args[1]))
1383     except UnterminatedStringError, x:
1384         printError(
1385             "While scanning search expression: unterminated string starting at"
1386             " character %d.\n" % x.args[0])
1387     except BadTokenError, x:
1388         printError(
1389             "While scanning search expression: bad token starting at character"
1390             " %d.\n" % x.args[0])
1391     except ParseError, x:
1392         printError(
1393             "While parsing search expression: %s.\n" % x.args[0])
1394     except UnknownImageVersionTypeError, x:
1395         printError("Unknown image version type: \"%s\".\n" % x.args[0])
1396     except ExifImportError, x:
1397         printError("Failed to import EXIF information from \"%s\".\n" % (
1398             x.args[0]))
1399     except KeyboardInterrupt:
1400         printOutput("Interrupted.\n")
1401     except IOError, e:
1402         if e.filename:
1403             errstr = "%s: \"%s\"" % (e.strerror, e.filename)
1404         else:
1405             errstr = e.strerror
1406         printError("%s.\n" % errstr)
1407     env.shelf.rollback()
1408     sys.exit(1)