885b659174dbfb4556204540284209b502a5c57e
[joel/kofoto.git] / src / packages / kofoto / shelf.py
1 """Interface to a Kofoto shelf."""
2
3 ######################################################################
4 ### Public names.
5
6 __all__ = [
7     "Shelf",
8     "computeImageHash",
9     "makeValidTag",
10     "verifyValidAlbumTag",
11     "verifyValidCategoryTag",
12 ]
13
14 ######################################################################
15 ### Libraries.
16
17 import os
18 import re
19 import threading
20 import sqlite as sql
21 from kofoto.dag import DAG, LoopError
22 from kofoto.cachedobject import CachedObject
23 from kofoto.albumtype import AlbumType
24 from kofoto.imageversiontype import ImageVersionType
25 import kofoto.exifthumbsupport
26 from kofoto import shelfupgrade
27 from kofoto import shelfschema
28 from kofoto.shelfexceptions import \
29     AlbumDoesNotExistError, \
30     AlbumExistsError, \
31     BadAlbumTagError, \
32     BadCategoryTagError, \
33     CategoriesAlreadyConnectedError, \
34     CategoryDoesNotExistError, \
35     CategoryExistsError, \
36     CategoryLoopError, \
37     CategoryPresentError, \
38     ExifImportError, \
39     FailedWritingError, \
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 import warnings
55 warnings.filterwarnings("ignore", "DB-API extension")
56 warnings.filterwarnings(
57     "ignore",
58     ".*losing bits or changing sign will return a long.*",
59     FutureWarning)
60
61
62 ######################################################################
63 ### Constants.
64
65 _ROOT_ALBUM_ID = 0
66 _SHELF_FORMAT_VERSION = 3
67
68
69 ######################################################################
70 ### Public functions.
71
72 def computeImageHash(filename):
73     """Compute the canonical image ID for an image file."""
74     import md5
75     m = md5.new()
76     f = file(filename, "rb")
77     while True:
78         data = f.read(2**16)
79         if not data:
80             break
81         m.update(data)
82     return unicode(m.hexdigest())
83
84
85 def verifyValidAlbumTag(tag):
86     """Verify that an album tag is valid."""
87     if not isinstance(tag, (str, unicode)):
88         raise BadAlbumTagError(tag)
89     try:
90         int(tag)
91     except ValueError:
92         if not tag or tag[0] == "@" or re.search(r"\s", tag):
93             raise BadAlbumTagError(tag)
94     else:
95         raise BadAlbumTagError(tag)
96
97
98 def verifyValidCategoryTag(tag):
99     """Verify that a category tag is valid."""
100     if not isinstance(tag, (str, unicode)):
101         raise BadCategoryTagError(tag)
102     try:
103         int(tag)
104     except ValueError:
105         if (not tag or tag[0] == "@" or re.search(r"\s", tag) or
106             tag in ["and", "exactly", "not", "or"]):
107             raise BadCategoryTagError(tag)
108     else:
109         raise BadCategoryTagError(tag)
110
111
112 def makeValidTag(tag):
113     """Make a string a valid tag.
114
115     Returns the valid tag string.
116     """
117     tag = tag.lstrip("@")
118     tag = re.sub(r"\s", "", tag)
119     if re.match("^\d+$", tag):
120         tag += u"_"
121     if not tag:
122         tag = u"_"
123     return tag
124
125
126 ######################################################################
127 ### Public classes.
128
129 class Shelf:
130     """A Kofoto shelf."""
131
132     ##############################
133     # Public methods.
134
135     def __init__(self, location):
136         """Constructor.
137
138         Location is where the database is located.
139         """
140         self.location = location
141         self.transactionLock = threading.Lock()
142         self.inTransaction = False
143         self.objectcache = {}
144         self.imageversioncache = {}
145         self.categorycache = {}
146         self.orphanAlbumsCache = None
147         self.orphanImagesCache = None
148         self.modified = False
149         self.modificationCallbacks = []
150         if False: # Set to True for debug log.
151             self.logfile = file("sql.log", "a")
152         else:
153             self.logfile = None
154         self.connection = None
155         self.categorydag = None
156
157
158     def create(self):
159         """Create the shelf."""
160         assert not self.inTransaction
161         if os.path.exists(self.location):
162             raise FailedWritingError(self.location)
163         try:
164             self.connection = _UnicodeConnectionDecorator(
165                 sql.connect(self.location,
166                             client_encoding="UTF-8",
167                             command_logfile=self.logfile),
168                 "UTF-8")
169         except sql.DatabaseError:
170             raise FailedWritingError(self.location)
171         self._createShelf()
172
173
174     def isUpgradable(self):
175         """Check whether the database format is upgradable.
176
177         This method must currently be called outside a transaction.
178
179         If this method returns True, run Shelf.tryUpgrade.
180         """
181         assert not self.inTransaction
182         return shelfupgrade.isUpgradable(self.location)
183
184
185     def tryUpgrade(self):
186         """Try to upgrade the database to a newer format.
187
188         This method must currently be called outside a transaction.
189
190         Returns True if upgrade was successful, otherwise False.
191         """
192         assert not self.inTransaction
193         return shelfupgrade.tryUpgrade(self.location, _SHELF_FORMAT_VERSION)
194
195
196     def begin(self):
197         """Begin working with the shelf."""
198         assert not self.inTransaction
199         self.transactionLock.acquire()
200         self.inTransaction = True
201         if not os.path.exists(self.location):
202             raise ShelfNotFoundError(self.location)
203         try:
204             self.connection = _UnicodeConnectionDecorator(
205                 sql.connect(self.location,
206                             client_encoding="UTF-8",
207                             command_logfile=self.logfile),
208                 "UTF-8")
209         except sql.OperationalError:
210             raise ShelfLockedError(self.location)
211         except sql.DatabaseError:
212             raise ShelfNotFoundError(self.location)
213         self.categorydag = CachedObject(_createCategoryDAG, (self.connection,))
214         try:
215             self._openShelf() # Starts the SQLite transaction.
216         except:
217             self.inTransaction = False
218             self.transactionLock.release()
219             raise
220
221
222     def commit(self):
223         """Commit the work on the shelf."""
224         assert self.inTransaction
225         try:
226             self.connection.commit()
227         finally:
228             self.flushCategoryCache()
229             self.flushObjectCache()
230             self.flushImageVersionCache()
231             self._unsetModified()
232             self.inTransaction = False
233             self.transactionLock.release()
234
235
236     def rollback(self):
237         """Abort the work on the shelf.
238
239         The changes (if any) will not be saved."""
240         assert self.inTransaction
241         try:
242             self.connection.rollback()
243         finally:
244             self.flushCategoryCache()
245             self.flushObjectCache()
246             self.flushImageVersionCache()
247             self._unsetModified()
248             self.inTransaction = False
249             self.transactionLock.release()
250
251
252     def isModified(self):
253         """Check whether the shelf has uncommited changes."""
254         assert self.inTransaction
255         return self.modified
256
257
258     def registerModificationCallback(self, callback):
259         """Register a function that will be called when the
260         modification status changes.
261
262         The function will receive a single argument: True if the shelf
263         has been modified, otherwise False. """
264         self.modificationCallbacks.append(callback)
265
266
267     def unregisterModificationCallback(self, callback):
268         """Unregister a modification callback function."""
269         try:
270             self.modificationCallbacks.remove(callback)
271         except ValueError:
272             pass
273
274
275     def flushCategoryCache(self):
276         """Flush the category cache."""
277         assert self.inTransaction
278         self.categorydag.invalidate()
279         self.categorycache = {}
280
281
282     def flushObjectCache(self):
283         """Flush the object cache."""
284         assert self.inTransaction
285         self.objectcache = {}
286         self.orphanAlbumsCache = None
287         self.orphanImagesCache = None
288
289
290     def flushImageVersionCache(self):
291         """Flush the image version cache."""
292         assert self.inTransaction
293         self.imageversioncache = {}
294
295
296     def getStatistics(self):
297         """Get statistics about the metadata database.
298
299         The returned value is a mapping with the following keys:
300
301         nalbums        -- Number of albums.
302         ncategories    -- Number of categories.
303         nimages        -- Number of images.
304         nimageversions -- Number of image versions.
305         """
306         assert self.inTransaction
307         cursor = self.connection.cursor()
308         cursor.execute(
309             " select count(*)"
310             " from   album")
311         nalbums = int(cursor.fetchone()[0])
312         cursor.execute(
313             " select count(*)"
314             " from   category")
315         ncategories = int(cursor.fetchone()[0])
316         cursor.execute(
317             " select count(*)"
318             " from   image")
319         nimages = int(cursor.fetchone()[0])
320         cursor.execute(
321             " select count(*)"
322             " from   image_version")
323         nimageversions = int(cursor.fetchone()[0])
324         return {
325             "nalbums": nalbums,
326             "ncategories": ncategories,
327             "nimages": nimages,
328             "nimageversions": nimageversions,
329             }
330
331
332     def createAlbum(self, tag, albumtype=AlbumType.Plain):
333         """Create an empty, orphaned album.
334
335         Returns an Album instance."""
336         assert self.inTransaction
337         verifyValidAlbumTag(tag)
338         cursor = self.connection.cursor()
339         try:
340             cursor.execute(
341                 " insert into object (id)"
342                 " values (null)")
343             lastrowid = cursor.lastrowid
344             cursor.execute(
345                 " insert into album (id, tag, deletable, type)"
346                 " values (%s, %s, 1, %s)",
347                 lastrowid,
348                 tag,
349                 _albumTypeToIdentifier(albumtype))
350             self._setModified()
351             self.orphanAlbumsCache = None
352             return self.getAlbum(lastrowid)
353         except sql.IntegrityError:
354             cursor.execute(
355                 " delete from object"
356                 " where id = %s",
357                 cursor.lastrowid)
358             raise AlbumExistsError(tag)
359
360
361     def getAlbum(self, albumid):
362         """Get the album for a given album ID.
363
364         Returns an Album instance.
365         """
366         assert self.inTransaction
367         if albumid in self.objectcache:
368             album = self.objectcache[albumid]
369             if not album.isAlbum():
370                 raise AlbumDoesNotExistError(albumid)
371             return album
372         cursor = self.connection.cursor()
373         cursor.execute(
374             " select id, tag, type"
375             " from   album"
376             " where  id = %s",
377             albumid)
378         row = cursor.fetchone()
379         if not row:
380             raise AlbumDoesNotExistError(albumid)
381         albumid, tag, albumtype = row
382         albumtype = _albumTypeIdentifierToType(albumtype)
383         album = self._albumFactory(albumid, tag, albumtype)
384         return album
385
386
387     def getAlbumByTag(self, tag):
388         """Get the album for a given album tag.
389
390         Returns an Album instance.
391         """
392         assert self.inTransaction
393         cursor = self.connection.cursor()
394         cursor.execute(
395             " select id"
396             " from   album"
397             " where  tag = %s",
398             tag)
399         row = cursor.fetchone()
400         if not row:
401             raise AlbumDoesNotExistError(tag)
402         return self.getAlbum(int(row[0]))
403
404
405     def getRootAlbum(self):
406         """Get the root album.
407
408         Returns an Album instance.
409         """
410         assert self.inTransaction
411         return self.getAlbum(_ROOT_ALBUM_ID)
412
413
414     def getAllAlbums(self):
415         """Get all albums in the shelf (unsorted).
416
417         Returns an iterable returning the albums."""
418         assert self.inTransaction
419         cursor = self.connection.cursor()
420         cursor.execute(
421             " select id, tag, type"
422             " from   album")
423         for albumid, tag, albumtype in cursor:
424             if albumid in self.objectcache:
425                 yield self.objectcache[albumid]
426             else:
427                 albumtype = _albumTypeIdentifierToType(albumtype)
428                 yield self._albumFactory(albumid, tag, albumtype)
429
430
431     def getAllImages(self):
432         """Get all images in the shelf (unsorted).
433
434         Returns an iterable returning the images."""
435         assert self.inTransaction
436         cursor = self.connection.cursor()
437         cursor.execute(
438             " select id, primary_version"
439             " from   image")
440         for (imageid, primary_version_id) in cursor:
441             if imageid in self.objectcache:
442                 yield self.objectcache[imageid]
443             else:
444                 yield self._imageFactory(imageid, primary_version_id)
445
446
447     def getAllImageVersions(self):
448         """Get all image versions in the shelf (unsorted).
449
450         Returns an iterable returning the image versions."""
451         assert self.inTransaction
452         cursor = self.connection.cursor()
453         cursor.execute(
454             " select id, image, type, hash, directory, filename, mtime,"
455             "        width, height, comment"
456             " from   image_version")
457         for (ivid, imageid, ivtype, ivhash, directory,
458              filename, mtime, width, height, comment) in cursor:
459             location = os.path.join(directory, filename)
460             if ivid in self.imageversioncache:
461                 yield self.imageversioncache[ivid]
462             else:
463                 ivtype = _imageVersionTypeIdentifierToType(ivtype)
464                 yield self._imageVersionFactory(
465                     ivid, imageid, ivtype, ivhash, location, mtime,
466                     width, height, comment)
467
468
469     def getImageVersionsInDirectory(self, directory):
470         """Get all image versions that are expected to be in a given
471         directory (unsorted).
472
473         Returns an iterable returning the image versions."""
474         assert self.inTransaction
475         directory = unicode(os.path.realpath(directory))
476         cursor = self.connection.cursor()
477         cursor.execute(
478             " select id, image, type, hash, directory, filename, mtime,"
479             "        width, height, comment"
480             " from   image_version"
481             " where  directory = %s",
482             directory)
483         for (ivid, imageid, ivtype, ivhash, directory, filename,
484              mtime, width, height, comment) in cursor:
485             location = os.path.join(directory, filename)
486             if ivid in self.imageversioncache:
487                 yield self.imageversioncache[ivid]
488             else:
489                 ivtype = _imageVersionTypeIdentifierToType(ivtype)
490                 yield self._imageVersionFactory(
491                     ivid, imageid, ivtype, ivhash, location, mtime,
492                     width, height, comment)
493
494
495     def deleteAlbum(self, albumid):
496         """Delete an album."""
497         assert self.inTransaction
498         cursor = self.connection.cursor()
499         cursor.execute(
500             " select id, tag"
501             " from   album"
502             " where  id = %s",
503             albumid)
504         row = cursor.fetchone()
505         if not row:
506             raise AlbumDoesNotExistError(albumid)
507         albumid, tag = row
508         if albumid == _ROOT_ALBUM_ID:
509             # Don't delete the root album!
510             raise UndeletableAlbumError(tag)
511         cursor.execute(
512             " delete from album"
513             " where  id = %s",
514             albumid)
515         self._deleteObjectFromParents(albumid)
516         cursor.execute(
517             " delete from member"
518             " where  album = %s",
519             albumid)
520         cursor.execute(
521             " delete from object"
522             " where  id = %s",
523             albumid)
524         cursor.execute(
525             " delete from attribute"
526             " where  object = %s",
527             albumid)
528         cursor.execute(
529             " delete from object_category"
530             " where  object = %s",
531             albumid)
532         if albumid in self.objectcache:
533             del self.objectcache[albumid]
534         self._setModified()
535         self.orphanAlbumsCache = None
536
537
538     def createImage(self):
539         """Create a new, orphaned image.
540
541         Returns an Image instance."""
542         assert self.inTransaction
543         cursor = self.connection.cursor()
544         cursor.execute(
545             " insert into object (id)"
546             " values (null)")
547         imageid = cursor.lastrowid
548         cursor.execute(
549             " insert into image (id, primary_version)"
550             " values (%s, NULL)",
551             imageid)
552         self._setModified()
553         self.orphanImagesCache = None
554         return self.getImage(imageid)
555
556
557     def getImage(self, imageid):
558         """Get the image for a given ID.
559
560         Returns an Image instance.
561         """
562         assert self.inTransaction
563         if imageid in self.objectcache:
564             image = self.objectcache[imageid]
565             if image.isAlbum():
566                 raise ImageDoesNotExistError(imageid)
567             return image
568         cursor = self.connection.cursor()
569         cursor.execute(
570             " select id, primary_version"
571             " from   image"
572             " where  id = %s",
573             imageid)
574         row = cursor.fetchone()
575         if not row:
576             raise ImageDoesNotExistError(imageid)
577         imageid, primary_version_id = row
578         image = self._imageFactory(imageid, primary_version_id)
579         return image
580
581
582     def createImageVersion(self, image, location, ivtype):
583         """Create a new image version.
584
585         Returns an ImageVersion instance."""
586         assert ivtype in ImageVersionType
587         assert self.inTransaction
588         import Image as PILImage
589         try:
590             pilimg = PILImage.open(location)
591             if not pilimg.mode in ("L", "RGB", "CMYK"):
592                 pilimg = pilimg.convert("RGB")
593 #        except IOError:
594         except: # Work-around for buggy PIL.
595             raise NotAnImageFileError(location)
596         width, height = pilimg.size
597         location = os.path.realpath(location)
598         mtime = os.path.getmtime(location)
599         ivhash = computeImageHash(location)
600         cursor = self.connection.cursor()
601         try:
602             cursor.execute(
603                 " insert into image_version"
604                 "     (image, type, hash, directory, filename,"
605                 "      mtime, width, height, comment)"
606                 " values"
607                 "     (%s, %s, %s, %s, %s, %s, %s, %s, '')",
608                 image.getId(),
609                 _imageVersionTypeToIdentifier(ivtype),
610                 ivhash,
611                 os.path.dirname(location),
612                 os.path.basename(location),
613                 mtime,
614                 width,
615                 height)
616         except sql.IntegrityError:
617             raise ImageVersionExistsError(location)
618         ivid = cursor.lastrowid
619         imageversion = self._imageVersionFactory(
620             ivid, image.getId(), ivtype, ivhash, location, mtime,
621             width, height, u"")
622         try:
623             imageversion.importExifTags(False)
624         except ExifImportError:
625             # Ignore exceptions from buggy EXIF library for now.
626             pass
627         if image.getPrimaryVersion() == None:
628             image._makeNewPrimaryVersion()
629         self._setModified()
630         return imageversion
631
632
633     def getImageVersion(self, ivid):
634         """Get the image version for a given ID.
635
636         Returns an ImageVersion instance.
637         """
638         assert self.inTransaction
639
640         if ivid in self.imageversioncache:
641             return self.imageversioncache[ivid]
642
643         cursor = self.connection.cursor()
644         cursor.execute(
645             " select id, image, type, hash, directory, filename, mtime,"
646             "        width, height, comment"
647             " from   image_version"
648             " where  id = %s",
649             ivid)
650         row = cursor.fetchone()
651         if not row:
652             raise ImageVersionDoesNotExistError(ivid)
653         ivid, imageid, ivtype, ivhash, directory, filename, mtime, \
654             width, height, comment = row
655         location = os.path.join(directory, filename)
656         ivtype = _imageVersionTypeIdentifierToType(ivtype)
657         return self._imageVersionFactory(
658             ivid, imageid, ivtype, ivhash, location, mtime,
659             width, height, comment)
660
661
662     def getImageVersionByHash(self, ivhash):
663         """Get the image version for a given hash.
664
665         Returns an ImageVersion instance.
666         """
667         assert self.inTransaction
668
669         cursor = self.connection.cursor()
670         cursor.execute(
671             " select id"
672             " from   image_version"
673             " where  hash = %s",
674             ivhash)
675         row = cursor.fetchone()
676         if not row:
677             raise ImageVersionDoesNotExistError(ivhash)
678         return self.getImageVersion(row[0])
679
680
681     def getImageVersionByLocation(self, location):
682         """Get the image version for a given location.
683
684         Note, though, that an image location is not required to be
685         unique in the shelf; if several image versions have the same
686         location, MultipleImageVersionsAtOneLocationError is raised.
687
688         Returns an ImageVersion instance.
689         """
690         assert self.inTransaction
691
692         location = os.path.abspath(location)
693         cursor = self.connection.cursor()
694         cursor.execute(
695             " select id"
696             " from   image_version"
697             " where  directory = %s and filename = %s",
698             os.path.dirname(location),
699             os.path.basename(location))
700         if cursor.rowcount > 1:
701             raise MultipleImageVersionsAtOneLocationError(location)
702         row = cursor.fetchone()
703         if not row:
704             raise ImageVersionDoesNotExistError(location)
705         return self.getImageVersion(row[0])
706
707
708     def deleteImage(self, imageid):
709         """Delete an image."""
710         assert self.inTransaction
711
712         cursor = self.connection.cursor()
713         cursor.execute(
714             " select 1"
715             " from   image"
716             " where  id = %s",
717             imageid)
718         if cursor.rowcount == 0:
719             raise ImageDoesNotExistError(imageid)
720         cursor.execute(
721             " select id"
722             " from   image_version"
723             " where  image = %s",
724             imageid)
725         for (ivid,) in cursor:
726             if ivid in self.imageversioncache:
727                 del self.imageversioncache[ivid]
728         cursor.execute(
729             " delete from image_version"
730             " where  image = %s",
731             imageid)
732         cursor.execute(
733             " delete from image"
734             " where  id = %s",
735             imageid)
736         self._deleteObjectFromParents(imageid)
737         cursor.execute(
738             " delete from object"
739             " where  id = %s",
740             imageid)
741         cursor.execute(
742             " delete from attribute"
743             " where  object = %s",
744             imageid)
745         cursor.execute(
746             " delete from object_category"
747             " where  object = %s",
748             imageid)
749         if imageid in self.objectcache:
750             del self.objectcache[imageid]
751         self._setModified()
752         self.orphanImagesCache = None
753
754
755     def deleteImageVersion(self, ivid):
756         """Delete an image version."""
757         assert self.inTransaction
758
759         image = self.getImageVersion(ivid).getImage()
760         primary_version_id = image.getPrimaryVersion().getId()
761         cursor = self.connection.cursor()
762         cursor.execute(
763             " delete from image_version"
764             " where  id = %s",
765             ivid)
766         if primary_version_id == ivid:
767             image._makeNewPrimaryVersion()
768         if ivid in self.imageversioncache:
769             del self.imageversioncache[ivid]
770         self._setModified()
771
772
773     def getObject(self, objid):
774         """Get the object for a given object ID."""
775         assert self.inTransaction
776         if objid in self.objectcache:
777             return self.objectcache[objid]
778         try:
779             return self.getImage(objid)
780         except ImageDoesNotExistError:
781             try:
782                 return self.getAlbum(objid)
783             except AlbumDoesNotExistError:
784                 raise ObjectDoesNotExistError(objid)
785
786
787     def deleteObject(self, objid):
788         """Get the object for a given object ID."""
789         assert self.inTransaction
790         try:
791             self.deleteImage(objid)
792         except ImageDoesNotExistError:
793             try:
794                 self.deleteAlbum(objid)
795             except AlbumDoesNotExistError:
796                 raise ObjectDoesNotExistError(objid)
797
798
799     def getAllAttributeNames(self):
800         """Get all used attribute names in the shelf (sorted).
801
802         Returns an iterable returning the attribute names."""
803         assert self.inTransaction
804         cursor = self.connection.cursor()
805         cursor.execute(
806             " select distinct name"
807             " from   attribute"
808             " order by name")
809         for (name,) in cursor:
810             yield name
811
812
813     def createCategory(self, tag, desc):
814         """Create a category.
815
816         Returns a Category instance."""
817         assert self.inTransaction
818         verifyValidCategoryTag(tag)
819         try:
820             cursor = self.connection.cursor()
821             cursor.execute(
822                 " insert into category (tag, description)"
823                 " values (%s, %s)",
824                 tag,
825                 desc)
826             self.categorydag.get().add(cursor.lastrowid)
827             self._setModified()
828             return self.getCategory(cursor.lastrowid)
829         except sql.IntegrityError:
830             raise CategoryExistsError(tag)
831
832
833     def deleteCategory(self, catid):
834         """Delete a category for a given category ID."""
835         assert self.inTransaction
836
837         cursor = self.connection.cursor()
838         cursor.execute(
839             " select tag"
840             " from   category"
841             " where  id = %s",
842             catid)
843         row = cursor.fetchone()
844         if not row:
845             raise CategoryDoesNotExistError(catid)
846         cursor.execute(
847             " delete from category_child"
848             " where  parent = %s",
849             catid)
850         cursor.execute(
851             " delete from category_child"
852             " where  child = %s",
853             catid)
854         cursor.execute(
855             " select object from object_category"
856             " where  category = %s",
857             catid)
858         for (objectid,) in cursor:
859             if objectid in self.objectcache:
860                 self.objectcache[objectid]._categoriesDirty()
861         cursor.execute(
862             " delete from object_category"
863             " where  category = %s",
864             catid)
865         cursor.execute(
866             " delete from category"
867             " where  id = %s",
868             catid)
869         catdag = self.categorydag.get()
870         if catid in catdag:
871             catdag.remove(catid)
872         if catid in self.categorycache:
873             del self.categorycache[catid]
874         self._setModified()
875
876
877     def getCategory(self, catid):
878         """Get a category for a given category tag/ID.
879
880         Returns a Category instance."""
881         assert self.inTransaction
882
883         if catid in self.categorycache:
884             return self.categorycache[catid]
885         cursor = self.connection.cursor()
886         cursor.execute(
887             " select tag, description"
888             " from   category"
889             " where  id = %s",
890             catid)
891         row = cursor.fetchone()
892         if not row:
893             raise CategoryDoesNotExistError(catid)
894         tag, desc = row
895         category = Category(self, catid, tag, desc)
896         self.categorycache[catid] = category
897         return category
898
899
900     def getCategoryByTag(self, tag):
901         """Get a category for a given category tag.
902
903         Returns a Category instance."""
904         assert self.inTransaction
905
906         cursor = self.connection.cursor()
907         cursor.execute(
908             " select id"
909             " from   category"
910             " where  tag = %s",
911             tag)
912         row = cursor.fetchone()
913         if not row:
914             raise CategoryDoesNotExistError(tag)
915         return self.getCategory(row[0])
916
917
918     def getRootCategories(self):
919         """Get the categories that are roots, i.e. have no parents.
920
921         Returns an iterable returning Category instances."""
922         assert self.inTransaction
923         for catid in self.categorydag.get().getRoots():
924             yield self.getCategory(catid)
925
926
927     def getMatchingCategories(self, regexp):
928         """Get the categories that case insensitively match a given
929         compiled regexp object.
930
931         Returns an iterable returning Category instances."""
932         assert self.inTransaction
933         for catid in self.categorydag.get():
934             category = self.getCategory(catid)
935             if (regexp.match(category.getTag().lower()) or
936                 regexp.match(category.getDescription().lower())):
937                 yield category
938
939
940     def search(self, searchtree):
941         """Search for objects matching a search node tree.
942
943         Use kofoto.search.Parser to construct a search node tree from
944         a string.
945
946         Returns an iterable returning the objects."""
947         assert self.inTransaction
948         cursor = self.connection.cursor()
949         cursor.execute(searchtree.getQuery())
950         for (objid,) in cursor:
951             yield self.getObject(objid)
952
953     ##############################
954     # Internal methods.
955
956     def _createShelf(self):
957         """Helper method for Shelf.create."""
958         cursor = self.connection.cursor()
959         cursor.execute(shelfschema.schema)
960         cursor.execute(
961             " insert into dbinfo (version)"
962             " values (%s)",
963             _SHELF_FORMAT_VERSION)
964         cursor.execute(
965             " insert into object (id)"
966             " values (%s)",
967             _ROOT_ALBUM_ID)
968         cursor.execute(
969             " insert into album (id, tag, deletable, type)"
970             " values (%s, %s, 0, 'plain')",
971             _ROOT_ALBUM_ID,
972             u"root")
973         self.connection.commit()
974
975         self.begin()
976         rootalbum = self.getRootAlbum()
977         rootalbum.setAttribute(u"title", u"Root album")
978         orphansalbum = self.createAlbum(u"orphans", AlbumType.Orphans)
979         orphansalbum.setAttribute(u"title", u"Orphans")
980         orphansalbum.setAttribute(
981             u"description",
982             u"This album contains albums and images that are not" +
983             u" linked from any album.")
984         self.getRootAlbum().setChildren([orphansalbum])
985         self.createCategory(u"events", u"Events")
986         self.createCategory(u"locations", u"Locations")
987         self.createCategory(u"people", u"People")
988         self.commit()
989
990
991     def _openShelf(self):
992         """Helper method for Shelf.open."""
993         cursor = self.connection.cursor()
994         try:
995             cursor.execute(
996                 " select version"
997                 " from   dbinfo")
998         except sql.OperationalError:
999             raise ShelfLockedError(self.location)
1000         except sql.DatabaseError:
1001             raise UnsupportedShelfError(self.location)
1002         version = cursor.fetchone()[0]
1003         if version != _SHELF_FORMAT_VERSION:
1004             raise UnsupportedShelfError(self.location)
1005
1006
1007     def _albumFactory(self, albumid, tag, albumtype):
1008         """Factory method for creating Album instances.
1009
1010         Arguments:
1011
1012         albumid   -- ID of the album.
1013         tag       -- Tag of the album.
1014         albumtype -- An instance of the AlbumType alternative.
1015         """
1016         albumtypemap = {
1017             AlbumType.Orphans: OrphansAlbum,
1018             AlbumType.Plain: PlainAlbum,
1019             AlbumType.Search: SearchAlbum,
1020         }
1021         album = albumtypemap[albumtype](self, albumid, tag, albumtype)
1022         self.objectcache[albumid] = album
1023         return album
1024
1025
1026     def _imageFactory(self, imageid, primary_version_id):
1027         """Factory method for creating Image instances.
1028
1029         Arguments:
1030
1031         imageid            -- ID of the image.
1032         primary_version_id -- ID of the primary image version.
1033         """
1034         image = Image(self, imageid, primary_version_id)
1035         self.objectcache[imageid] = image
1036         return image
1037
1038
1039     def _imageVersionFactory(self, ivid, imageid, ivtype, ivhash,
1040                              location, mtime, width, height, comment):
1041         """Factory method for creating ImageVersion instances.
1042
1043         Arguments:
1044
1045         ivid     -- ID of the image version.
1046         imageid  -- ID of the image the image version belongs to.
1047         ivtype   -- An instance of the ImageVersionType alternative.
1048         ivhash   -- Hash of the image version file.
1049         location -- Location of the image version file.
1050         mtime    -- mtime of the image version file.
1051         width    -- Width of the image version.
1052         height   -- Height of the image version.
1053         comment  -- Comment of the image version.
1054         """
1055         imageversion = ImageVersion(
1056             self, ivid, imageid, ivtype, ivhash, location, mtime, width,
1057             height, comment)
1058         self.imageversioncache[ivid] = imageversion
1059         return imageversion
1060
1061
1062     def _deleteObjectFromParents(self, objid):
1063         """Helper method that deletes an object from its parents."""
1064         cursor = self.connection.cursor()
1065         cursor.execute(
1066             " select distinct album.id, album.tag"
1067             " from   member, album"
1068             " where  member.object = %s and member.album = album.id",
1069             objid)
1070         parentinfolist = cursor.fetchall()
1071         for parentid, _ in parentinfolist:
1072             cursor.execute(
1073                 " select position"
1074                 " from   member"
1075                 " where  album = %s and object = %s"
1076                 " order by position desc",
1077                 parentid,
1078                 objid)
1079             positions = [x[0] for x in cursor.fetchall()]
1080             for position in positions:
1081                 cursor.execute(
1082                     " delete from member"
1083                     " where  album = %s and position = %s",
1084                     parentid,
1085                     position)
1086                 cursor.execute(
1087                     " update member"
1088                     " set    position = position - 1"
1089                     " where  album = %s and position > %s",
1090                     parentid,
1091                     position)
1092             if parentid in self.objectcache:
1093                 del self.objectcache[parentid]
1094
1095
1096     def _setModified(self):
1097         """Set the modified flag."""
1098         self.modified = True
1099         for fn in self.modificationCallbacks:
1100             fn(True)
1101
1102
1103     def _unsetModified(self):
1104         """Unset the modified flag."""
1105         self.modified = False
1106         for fn in self.modificationCallbacks:
1107             fn(False)
1108
1109
1110     def _getConnection(self):
1111         """Get the database connection instance."""
1112         assert self.inTransaction
1113         return self.connection
1114
1115
1116     def _getOrphanAlbumsCache(self):
1117         """Get the cache of the orphaned albums."""
1118         assert self.inTransaction
1119         return self.orphanAlbumsCache
1120
1121
1122     def _setOrphanAlbumsCache(self, albums):
1123         """Set the cache of the orphaned albums."""
1124         assert self.inTransaction
1125         self.orphanAlbumsCache = albums
1126
1127
1128     def _getOrphanImagesCache(self):
1129         """Get the cache of the orphaned images."""
1130         assert self.inTransaction
1131         return self.orphanImagesCache
1132
1133
1134     def _setOrphanImagesCache(self, images):
1135         """Set the cache of the orphaned images."""
1136         assert self.inTransaction
1137         self.orphanImagesCache = images
1138
1139
1140 class Category:
1141     """A Kofoto category."""
1142
1143     ##############################
1144     # Public methods.
1145
1146     def getId(self):
1147         """Get category ID."""
1148         return self.catid
1149
1150
1151     def getTag(self):
1152         """Get category tag."""
1153         return self.tag
1154
1155
1156     def setTag(self, newtag):
1157         """Set category tag."""
1158         verifyValidCategoryTag(newtag)
1159         cursor = self.shelf._getConnection().cursor()
1160         cursor.execute(
1161             " update category"
1162             " set    tag = %s"
1163             " where  id = %s",
1164             newtag,
1165             self.getId())
1166         self.tag = newtag
1167         self.shelf._setModified()
1168
1169
1170     def getDescription(self):
1171         """Get category description."""
1172         return self.description
1173
1174
1175     def setDescription(self, newdesc):
1176         """Set category description."""
1177         cursor = self.shelf._getConnection().cursor()
1178         cursor.execute(
1179             " update category"
1180             " set    description = %s"
1181             " where  id = %s",
1182             newdesc,
1183             self.getId())
1184         self.description = newdesc
1185         self.shelf._setModified()
1186
1187
1188     def getChildren(self, recursive=False):
1189         """Get child categories.
1190
1191         If recursive is true, get all descendants. If recursive is
1192         false, get only immediate children. Returns an iterable
1193         returning of Category instances (unordered)."""
1194         catdag = self.shelf.categorydag.get()
1195         if recursive:
1196             catiter = catdag.getDescendants(self.getId())
1197         else:
1198             catiter = catdag.getChildren(self.getId())
1199         for catid in catiter:
1200             yield self.shelf.getCategory(catid)
1201
1202
1203     def getParents(self, recursive=False):
1204         """Get parent categories.
1205
1206         If recursive is true, get all ancestors. If recursive is
1207         false, get only immediate parents. Returns an iterable
1208         returning of Category instances (unordered)."""
1209         catdag = self.shelf.categorydag.get()
1210         if recursive:
1211             catiter = catdag.getAncestors(self.getId())
1212         else:
1213             catiter = catdag.getParents(self.getId())
1214         for catid in catiter:
1215             yield self.shelf.getCategory(catid)
1216
1217
1218     def isChildOf(self, category, recursive=False):
1219         """Check whether this category is a child or descendant of a
1220         category.
1221
1222         If recursive is true, check if the category is a descendant of
1223         this category, otherwise just consider immediate children."""
1224         parentid = category.getId()
1225         childid = self.getId()
1226         catdag = self.shelf.categorydag.get()
1227         if recursive:
1228             return catdag.reachable(parentid, childid)
1229         else:
1230             return catdag.connected(parentid, childid)
1231
1232
1233     def isParentOf(self, category, recursive=False):
1234         """Check whether this category is a parent or ancestor of a
1235         category.
1236
1237         If recursive is true, check if the category is an ancestor of
1238         this category, otherwise just consider immediate parents."""
1239         return category.isChildOf(self, recursive)
1240
1241
1242     def connectChild(self, category):
1243         """Make parent-child link between this category and a category."""
1244         parentid = self.getId()
1245         childid = category.getId()
1246         if self.shelf.categorydag.get().connected(parentid, childid):
1247             raise CategoriesAlreadyConnectedError(
1248                 self.getTag(), category.getTag())
1249         try:
1250             self.shelf.categorydag.get().connect(parentid, childid)
1251         except LoopError:
1252             raise CategoryLoopError(self.getTag(), category.getTag())
1253         cursor = self.shelf._getConnection().cursor()
1254         cursor.execute(
1255             " insert into category_child (parent, child)"
1256             " values (%s, %s)",
1257             parentid,
1258             childid)
1259         self.shelf._setModified()
1260
1261
1262     def disconnectChild(self, category):
1263         """Remove a parent-child link between this category and a category."""
1264         parentid = self.getId()
1265         childid = category.getId()
1266         self.shelf.categorydag.get().disconnect(parentid, childid)
1267         cursor = self.shelf._getConnection().cursor()
1268         cursor.execute(
1269             " delete from category_child"
1270             " where  parent = %s and child = %s",
1271             parentid,
1272             childid)
1273         self.shelf._setModified()
1274
1275
1276     ##############################
1277     # Internal methods.
1278
1279     def __init__(self, shelf, catid, tag, description):
1280         self.shelf = shelf
1281         self.catid = catid
1282         self.tag = tag
1283         self.description = description
1284
1285
1286     def __eq__(self, obj):
1287         return isinstance(obj, Category) and obj.getId() == self.getId()
1288
1289
1290     def __ne__(self, obj):
1291         return not obj == self
1292
1293
1294     def __hash__(self):
1295         return self.getId()
1296
1297
1298 class _Object:
1299     """Abstract base class of Kofoto objects (albums and images)."""
1300
1301     ##############################
1302     # Public methods.
1303
1304     def isAlbum(self):
1305         """Return True if this an album, False if this is an image."""
1306         raise NotImplementedError
1307
1308     def getId(self):
1309         """Get the ID of an object."""
1310         return self.objid
1311
1312
1313     def getParents(self):
1314         """Get the parent albums of an object.
1315
1316         Returns an iterable returning the albums.
1317
1318         Note that the object may be included multiple times in a
1319         parent album."""
1320         cursor = self.shelf._getConnection().cursor()
1321         cursor.execute(
1322             " select distinct album.id"
1323             " from   member, album"
1324             " where  member.object = %s and"
1325             "        member.album = album.id",
1326             self.getId())
1327         for (albumid,) in cursor:
1328             yield self.shelf.getAlbum(albumid)
1329
1330
1331     def getAttribute(self, name):
1332         """Get the value of an attribute.
1333
1334         Returns the value as string, or None if there was no matching
1335         attribute.
1336         """
1337         if name in self.attributes:
1338             return self.attributes[name]
1339         cursor = self.shelf._getConnection().cursor()
1340         cursor.execute(
1341             " select value"
1342             " from   attribute"
1343             " where  object = %s and name = %s",
1344             self.getId(),
1345             name)
1346         if cursor.rowcount > 0:
1347             value = cursor.fetchone()[0]
1348             self.attributes[name] = value
1349         else:
1350             value = None
1351         return value
1352
1353
1354     def getAttributeMap(self):
1355         """Get a map of all attributes."""
1356         if self.allAttributesFetched:
1357             return self.attributes
1358         cursor = self.shelf._getConnection().cursor()
1359         cursor.execute(
1360             " select name, value"
1361             " from   attribute"
1362             " where  object = %s",
1363             self.getId())
1364         amap = {}
1365         for key, value in cursor:
1366             amap[key] = value
1367         self.attributes = amap
1368         self.allAttributesFetched = True
1369         return amap
1370
1371
1372     def getAttributeNames(self):
1373         """Get all attribute names.
1374
1375         Returns an iterable returning the attributes."""
1376         if not self.allAttributesFetched:
1377             self.getAttributeMap()
1378         return self.attributes.iterkeys()
1379
1380
1381     def setAttribute(self, name, value, overwrite=True):
1382         """Set an attribute value.
1383
1384         Iff overwrite is true, an existing attribute will be
1385         overwritten.
1386         """
1387         if overwrite:
1388             method = "replace"
1389         else:
1390             method = "ignore"
1391         cursor = self.shelf._getConnection().cursor()
1392         cursor.execute(
1393             " insert or " + method + " into attribute"
1394             "     (object, name, value, lcvalue)"
1395             " values"
1396             "     (%s, %s, %s, %s)",
1397             self.getId(),
1398             name,
1399             value,
1400             value.lower())
1401         if cursor.rowcount > 0:
1402             self.attributes[name] = value
1403             self.shelf._setModified()
1404
1405
1406     def deleteAttribute(self, name):
1407         """Delete an attribute."""
1408         cursor = self.shelf._getConnection().cursor()
1409         cursor.execute(
1410             " delete from attribute"
1411             " where  object = %s and name = %s",
1412             self.getId(),
1413             name)
1414         if name in self.attributes:
1415             del self.attributes[name]
1416         self.shelf._setModified()
1417
1418
1419     def addCategory(self, category):
1420         """Add a category."""
1421         objid = self.getId()
1422         catid = category.getId()
1423         try:
1424             cursor = self.shelf._getConnection().cursor()
1425             cursor.execute(
1426                 " insert into object_category (object, category)"
1427                 " values (%s, %s)",
1428                 objid,
1429                 catid)
1430             self.categories.add(catid)
1431             self.shelf._setModified()
1432         except sql.IntegrityError:
1433             raise CategoryPresentError(objid, category.getTag())
1434
1435
1436     def removeCategory(self, category):
1437         """Remove a category."""
1438         cursor = self.shelf._getConnection().cursor()
1439         catid = category.getId()
1440         cursor.execute(
1441             " delete from object_category"
1442             " where object = %s and category = %s",
1443             self.getId(),
1444             catid)
1445         self.categories.discard(catid)
1446         self.shelf._setModified()
1447
1448
1449     def getCategories(self, recursive=False):
1450         """Get categories for this object.
1451
1452         Returns an iterable returning the categories."""
1453         if not self.allCategoriesFetched:
1454             cursor = self.shelf._getConnection().cursor()
1455             cursor.execute(
1456                 " select category from object_category"
1457                 " where  object = %s",
1458                 self.getId())
1459             self.categories = set([x[0] for x in cursor])
1460             self.allCategoriesFetched = True
1461         if recursive:
1462             allcategories = set()
1463             for catid in self.categories:
1464                 allcategories |= set(
1465                     self.shelf.categorydag.get().getAncestors(catid))
1466         else:
1467             allcategories = self.categories
1468         for catid in allcategories:
1469             yield self.shelf.getCategory(catid)
1470
1471
1472     ##############################
1473     # Internal methods.
1474
1475     def __init__(self, shelf, objid):
1476         self.shelf = shelf
1477         self.objid = objid
1478         self.attributes = {}
1479         self.allAttributesFetched = False
1480         self.categories = set()
1481         self.allCategoriesFetched = False
1482
1483     def _categoriesDirty(self):
1484         """Set the categories dirty flag."""
1485         self.allCategoriesFetched = False
1486
1487
1488     def __eq__(self, obj):
1489         return isinstance(obj, _Object) and obj.getId() == self.getId()
1490
1491
1492     def __ne__(self, obj):
1493         return not obj == self
1494
1495
1496     def __hash__(self):
1497         return self.getId()
1498
1499
1500 class Album(_Object):
1501     """Abstract base class of Kofoto albums."""
1502
1503     ##############################
1504     # Public methods.
1505
1506     def getType(self):
1507         """Get the album type.
1508
1509         The returned value is an instance of the AlbumType alternative.
1510         """
1511         return self.albumtype
1512
1513
1514     def isMutable(self):
1515         """Whether the album can be modified with setChildren."""
1516         raise NotImplementedError
1517
1518
1519     def getTag(self):
1520         """Get the tag of the album."""
1521         return self.tag
1522
1523
1524     def setTag(self, newtag):
1525         """Set the tag of the album."""
1526         verifyValidAlbumTag(newtag)
1527         cursor = self.shelf._getConnection().cursor()
1528         cursor.execute(
1529             " update album"
1530             " set    tag = %s"
1531             " where  id = %s",
1532             newtag,
1533             self.getId())
1534         self.tag = newtag
1535         self.shelf._setModified()
1536
1537
1538     def getChildren(self):
1539         """Get the album's children.
1540
1541         Returns an iterable returning Album/Image instances.
1542         """
1543         raise NotImplementedError
1544
1545
1546     def getAlbumChildren(self):
1547         """Get the album's album children.
1548
1549         Returns an iterable returning Album instances.
1550         """
1551         raise NotImplementedError
1552
1553
1554     def getAlbumParents(self):
1555         """Get the album's (album) parents.
1556
1557         Returns an iterable returning Album instances.
1558         """
1559         cursor = self.shelf._getConnection().cursor()
1560         cursor.execute(
1561             " select distinct member.album"
1562             " from   member, album"
1563             " where  member.object = %s and"
1564             "        member.album = album.id",
1565             self.getId())
1566         for (objid,) in cursor:
1567             yield self.shelf.getAlbum(objid)
1568
1569
1570     def setChildren(self, children):
1571         """Set the children of the album.
1572
1573         Arguments:
1574
1575         children -- A list of Album/Image instances.
1576         """
1577         raise NotImplementedError
1578
1579
1580     def isAlbum(self):
1581         """Return True if this an album, False if this is an image."""
1582         return True
1583
1584
1585     ##############################
1586     # Internal methods.
1587
1588     def __init__(self, shelf, albumid, tag, albumtype):
1589         """Constructor of an Album."""
1590         _Object.__init__(self, shelf, albumid)
1591         self.shelf = shelf
1592         self.tag = tag
1593         self.albumtype = albumtype
1594
1595
1596 class PlainAlbum(Album):
1597     """A plain Kofoto album."""
1598
1599     ##############################
1600     # Public methods.
1601
1602     def isMutable(self):
1603         """Whether the album can be modified with setChildren."""
1604         return True
1605
1606
1607     def getChildren(self):
1608         """Get the album's children.
1609
1610         Returns an iterable returning Album/Images instances.
1611         """
1612         if self.children is not None:
1613             for child in self.children:
1614                 yield child
1615             return
1616         cursor = self.shelf._getConnection().cursor()
1617         cursor.execute(
1618             " select object"
1619             " from   member"
1620             " where  album = %s"
1621             " order by position",
1622             self.getId())
1623         self.children = []
1624         for (objid,) in cursor:
1625             child = self.shelf.getObject(objid)
1626             self.children.append(child)
1627         for child in self.children:
1628             yield child
1629
1630
1631     def getAlbumChildren(self):
1632         """Get the album's album children.
1633
1634         Returns an iterable returning Album instances.
1635         """
1636         if self.children is not None:
1637             for child in self.children:
1638                 if child.isAlbum():
1639                     yield child
1640             return
1641         cursor = self.shelf._getConnection().cursor()
1642         cursor.execute(
1643             " select member.object"
1644             " from   member, album"
1645             " where  member.album = %s and"
1646             "        member.object = album.id"
1647             " order by position",
1648             self.getId())
1649         for (objid,) in cursor:
1650             yield self.shelf.getAlbum(objid)
1651
1652
1653     def setChildren(self, children):
1654         """Set the album's children.
1655
1656         Arguments:
1657
1658         children -- A list of Album/Image instances.
1659         """
1660         albumid = self.getId()
1661         cursor = self.shelf._getConnection().cursor()
1662         cursor.execute(
1663             "-- types int")
1664         cursor.execute(
1665             " select count(position)"
1666             " from   member"
1667             " where  album = %s",
1668             albumid)
1669         oldchcnt = cursor.fetchone()[0]
1670         newchcnt = len(children)
1671         for ix in range(newchcnt):
1672             childid = children[ix].getId()
1673             if ix < oldchcnt:
1674                 cursor.execute(
1675                     " update member"
1676                     " set    object = %s"
1677                     " where  album = %s and position = %s",
1678                     childid,
1679                     albumid,
1680                     ix)
1681             else:
1682                 cursor.execute(
1683                     " insert into member (album, position, object)"
1684                     " values (%s, %s, %s)",
1685                     albumid,
1686                     ix,
1687                     childid)
1688         cursor.execute(
1689             " delete from member"
1690             " where  album = %s and position >= %s",
1691             albumid,
1692             newchcnt)
1693         self.shelf._setModified()
1694         self.shelf._setOrphanAlbumsCache(None)
1695         self.shelf._setOrphanImagesCache(None)
1696         self.children = children[:]
1697
1698     ##############################
1699     # Internal methods.
1700
1701     def __init__(self, *args):
1702         """Constructor of an Album."""
1703         Album.__init__(self, *args)
1704         self.children = None
1705
1706
1707 class Image(_Object):
1708     """A Kofoto image."""
1709
1710     ##############################
1711     # Public methods.
1712
1713     def isAlbum(self):
1714         """Return True if this an album, False if this is an image."""
1715         return False
1716
1717
1718     def getImageVersions(self):
1719         """Get the image versions for the image.
1720
1721         Returns an iterable returning ImageVersion instances.
1722         """
1723         cursor = self.shelf._getConnection().cursor()
1724         cursor.execute(
1725             " select id"
1726             " from   image_version"
1727             " where  image = %s"
1728             " order by id",
1729             self.getId())
1730         for (ivid,) in cursor:
1731             yield self.shelf.getImageVersion(ivid)
1732
1733
1734     def getPrimaryVersion(self):
1735         """Get the image's primary version.
1736
1737         Returns an ImageVersion instance, or None if the image has no
1738         versions.
1739         """
1740         if self.primary_version_id is None:
1741             return None
1742         else:
1743             return self.shelf.getImageVersion(self.primary_version_id)
1744
1745
1746     ##############################
1747     # Internal methods.
1748
1749     def __init__(self, shelf, imageid, primary_version_id):
1750         _Object.__init__(self, shelf, imageid)
1751         self.shelf = shelf
1752         self.primary_version_id = primary_version_id
1753
1754
1755     def _makeNewPrimaryVersion(self):
1756         """Helper method to make a new primary image version of needed."""
1757         ivs = list(self.getImageVersions())
1758         if len(ivs) > 0:
1759             # The last version is probably the best.
1760             self.primary_version_id = ivs[-1].getId()
1761         else:
1762             self.primary_version_id = None
1763         cursor = self.shelf._getConnection().cursor()
1764         cursor.execute(
1765             " update image"
1766             " set    primary_version = %s"
1767             " where  id = %s",
1768             self.primary_version_id,
1769             self.getId())
1770
1771
1772     def _setPrimaryVersion(self, imageversion):
1773         """Helper method to set the primary image version."""
1774         self.primary_version_id = imageversion.getId()
1775
1776
1777 class ImageVersion:
1778     """A Kofoto image version."""
1779
1780     ##############################
1781     # Public methods.
1782
1783     def getId(self):
1784         """Get the ID of the image version."""
1785         return self.id
1786
1787
1788     def getImage(self):
1789         """Get the image associated with the image version."""
1790         return self.shelf.getImage(self.imageid)
1791
1792
1793     def getType(self):
1794         """Get the type of the image version.
1795
1796         Returns ImageVersionType.Important, ImageVersionType.Original
1797         or ImageVersionType.Other."""
1798         return self.type
1799
1800
1801     def getComment(self):
1802         """Get the comment of the image version."""
1803         return self.comment
1804
1805
1806     def getHash(self):
1807         """Get the hash of the image version."""
1808         return self.hash
1809
1810
1811     def getLocation(self):
1812         """Get the last known location of the image version."""
1813         return self.location
1814
1815
1816     def getModificationTime(self):
1817         """Get the last known modification time of the image version."""
1818         return self.mtime
1819
1820
1821     def getSize(self):
1822         """Get the size of the image version."""
1823         return self.size
1824
1825
1826     def setImage(self, image):
1827         """Associate the image version with an image."""
1828         oldimage = self.getImage()
1829         if image == oldimage:
1830             return
1831         if oldimage.getPrimaryVersion() == self:
1832             oldImageNeedsNewPrimaryVersion = True
1833         else:
1834             oldImageNeedsNewPrimaryVersion = False
1835         self.imageid = image.getId()
1836         cursor = self.shelf._getConnection().cursor()
1837         cursor.execute(
1838             " update image_version"
1839             " set    image = %s"
1840             " where  id = %s",
1841             self.imageid,
1842             self.id)
1843         if image.getPrimaryVersion() == None:
1844             image._makeNewPrimaryVersion()
1845         if oldImageNeedsNewPrimaryVersion:
1846             oldimage._makeNewPrimaryVersion()
1847         self.shelf._setModified()
1848
1849
1850     def setType(self, ivtype):
1851         """Set the type of the image version.
1852
1853         Arguments:
1854
1855         ivtype -- An instance of the ImageVersionType alternative.
1856         """
1857         self.type = ivtype
1858         cursor = self.shelf._getConnection().cursor()
1859         cursor.execute(
1860             " update image_version"
1861             " set    type = %s"
1862             " where  id = %s",
1863             _imageVersionTypeToIdentifier(ivtype),
1864             self.id)
1865         self.shelf._setModified()
1866
1867
1868     def setComment(self, comment):
1869         """Set the comment of the image version."""
1870         self.comment = comment
1871         cursor = self.shelf._getConnection().cursor()
1872         cursor.execute(
1873             " update image_version"
1874             " set    comment = %s"
1875             " where  id = %s",
1876             comment,
1877             self.id)
1878         self.shelf._setModified()
1879
1880
1881     def makePrimary(self):
1882         """Make this image version the primary image version."""
1883         cursor = self.shelf._getConnection().cursor()
1884         cursor.execute(
1885             " update image"
1886             " set    primary_version = %s"
1887             " where  id = %s",
1888             self.id,
1889             self.imageid)
1890         self.getImage()._setPrimaryVersion(self)
1891         self.shelf._setModified()
1892
1893
1894     def isPrimary(self):
1895         """Whether the image version is primary."""
1896         return self.getImage().getPrimaryVersion() == self
1897
1898
1899     def contentChanged(self):
1900         """Record new image information for an edited image version.
1901
1902         Checksum, width, height and mtime are updated.
1903
1904         It is assumed that the image version location is still correct.
1905         """
1906         self.hash = computeImageHash(self.location)
1907         import Image as PILImage
1908         try:
1909             pilimg = PILImage.open(self.location)
1910         except IOError:
1911             raise NotAnImageFileError(self.location)
1912         self.size = pilimg.size
1913         self.mtime = os.path.getmtime(self.location)
1914         cursor = self.shelf._getConnection().cursor()
1915         cursor.execute(
1916             " update image_version"
1917             " set    hash = %s, width = %s, height = %s, mtime = %s"
1918             " where  id = %s",
1919             self.hash,
1920             self.size[0],
1921             self.size[1],
1922             self.mtime,
1923             self.getId())
1924         self.shelf._setModified()
1925
1926
1927     def locationChanged(self, location):
1928         """Set the last known location of the image version.
1929
1930         The mtime is also updated."""
1931         cursor = self.shelf._getConnection().cursor()
1932         location = unicode(os.path.realpath(location))
1933         try:
1934             self.mtime = os.path.getmtime(location)
1935         except OSError:
1936             self.mtime = 0
1937         cursor.execute(
1938             " update image_version"
1939             " set    directory = %s, filename = %s, mtime = %s"
1940             " where  id = %s",
1941             os.path.dirname(location),
1942             os.path.basename(location),
1943             self.mtime,
1944             self.getId())
1945         self.location = location
1946         self.shelf._setModified()
1947
1948
1949     def importExifTags(self, overwrite):
1950         """Read known EXIF tags and add them as attributes.
1951
1952         Iff overwrite is true, existing attributes will be
1953         overwritten.
1954
1955         Raises kofoto.shelfexceptions.ExifImportError on error.
1956         """
1957         from kofoto import EXIF
1958         image = self.getImage()
1959         fp = open(self.getLocation(), "rb")
1960         try:
1961             tags = EXIF.process_file(fp, details=False)
1962         except: # Work-around for buggy EXIF library.
1963             raise ExifImportError(self.getLocation())
1964
1965         for tag in ["Image DateTime",
1966                     "EXIF DateTimeOriginal",
1967                     "EXIF DateTimeDigitized"]:
1968             value = tags.get(tag)
1969             if value and str(value) != "0000:00:00 00:00:00":
1970                 m = re.match(
1971                     r"(\d{4})[:/-](\d{2})[:/-](\d{2}) (\d{2}):(\d{2}):(\d{2})",
1972                     str(value))
1973                 if m:
1974                     image.setAttribute(
1975                         u"captured",
1976                         u"%s-%s-%s %s:%s:%s" % m.groups())
1977
1978         value = tags.get("EXIF ExposureTime")
1979         if value:
1980             image.setAttribute(u"exposuretime", unicode(value), overwrite)
1981         value = tags.get("EXIF FNumber")
1982         if value:
1983             image.setAttribute(u"fnumber", unicode(value), overwrite)
1984         value = tags.get("EXIF Flash")
1985         if value:
1986             image.setAttribute(u"flash", unicode(value), overwrite)
1987         value = tags.get("EXIF FocalLength")
1988         if value:
1989             image.setAttribute(u"focallength", unicode(value), overwrite)
1990         value = tags.get("Image Make")
1991         if value:
1992             image.setAttribute(u"cameramake", unicode(value), overwrite)
1993         value = tags.get("Image Model")
1994         if value:
1995             image.setAttribute(u"cameramodel", unicode(value), overwrite)
1996         value = tags.get("Image Orientation")
1997         if value:
1998             try:
1999                 m = {1: "up",
2000                      2: "up",
2001                      3: "down",
2002                      4: "up",
2003                      5: "up",
2004                      6: "left",
2005                      7: "up",
2006                      8: "right",
2007                      }
2008                 image.setAttribute(
2009                     u"orientation", unicode(m[value.values[0]]), overwrite)
2010             except KeyError:
2011                 pass
2012         value = tags.get("EXIF ExposureProgram")
2013         if value:
2014             image.setAttribute(u"exposureprogram", unicode(value), overwrite)
2015         value = tags.get("EXIF ISOSpeedRatings")
2016         if value:
2017             image.setAttribute(u"iso", unicode(value), overwrite)
2018         value = tags.get("EXIF ExposureBiasValue")
2019         if value:
2020             image.setAttribute(u"exposurebias", unicode(value), overwrite)
2021         self.shelf._setModified()
2022
2023     ##############################
2024     # Internal methods.
2025
2026     def __init__(
2027         self, shelf, ivid, imageid, ivtype, ivhash, location, mtime, width,
2028         height, comment):
2029         """Constructor of an ImageVersion."""
2030         self.shelf = shelf
2031         self.id = ivid
2032         self.imageid = imageid
2033         self.type = ivtype
2034         self.hash = ivhash
2035         self.location = location
2036         self.mtime = mtime
2037         self.size = width, height
2038         self.comment = comment
2039
2040
2041 class MagicAlbum(Album):
2042     """Base class of magic albums."""
2043
2044     ##############################
2045     # Public methods.
2046
2047     def isMutable(self):
2048         """Whether the album can be modified with setChildren."""
2049         return False
2050
2051
2052     def setChildren(self, children):
2053         """Set the album's children.
2054
2055         Arguments:
2056
2057         children -- A list of Album/Image instances.
2058         """
2059         raise UnsettableChildrenError(self.getTag())
2060
2061
2062 class OrphansAlbum(MagicAlbum):
2063     """An album with all albums and images that are orphans."""
2064
2065     ##############################
2066     # Public methods.
2067
2068     def getChildren(self):
2069         """Get the album's children.
2070
2071         Returns an iterable returning the orphans.
2072         """
2073         return self._getChildren(True)
2074
2075
2076     def getAlbumChildren(self):
2077         """Get the album's album children.
2078
2079         Returns an iterable returning the orphans.
2080         """
2081         return self._getChildren(False)
2082
2083
2084     ##############################
2085     # Internal methods.
2086
2087     def _getChildren(self, includeimages):
2088         """Helper method to get the albums children.
2089
2090         Arguments:
2091
2092         includeimages -- Whether images should be included.
2093         """
2094         albums = self.shelf._getOrphanAlbumsCache()
2095         if albums != None:
2096             for album in albums:
2097                 yield album
2098         else:
2099             cursor = self.shelf._getConnection().cursor()
2100             cursor.execute(
2101                 " select   id"
2102                 " from     album"
2103                 " where    id not in (select object from member) and"
2104                 "          id != %s"
2105                 " order by tag",
2106                 _ROOT_ALBUM_ID)
2107             albums = []
2108             for (albumid,) in cursor:
2109                 album = self.shelf.getAlbum(albumid)
2110                 albums.append(album)
2111                 yield album
2112             self.shelf._setOrphanAlbumsCache(albums)
2113         if includeimages:
2114             images = self.shelf._getOrphanImagesCache()
2115             if images != None:
2116                 for image in images:
2117                     yield image
2118             else:
2119                 cursor = self.shelf._getConnection().cursor()
2120                 cursor.execute(
2121                     " select   i.id, i.primary_version"
2122                     " from     image as i left join attribute as a"
2123                     " on       i.id = a.object and a.name = 'captured'"
2124                     " where    i.id not in (select object from member)"
2125                     " order by a.lcvalue")
2126                 images = []
2127                 for (imageid, primary_version_id) in cursor:
2128                     image = self.shelf._imageFactory(
2129                         imageid, primary_version_id)
2130                     images.append(image)
2131                     yield image
2132                 self.shelf._setOrphanImagesCache(images)
2133
2134
2135 class SearchAlbum(MagicAlbum):
2136     """An album whose content is defined by a search string."""
2137
2138     ##############################
2139     # Public methods.
2140
2141     def getChildren(self):
2142         """Get the album's children.
2143
2144         Returns an iterable returning the children.
2145         """
2146         return self._getChildren(True)
2147
2148
2149     def getAlbumChildren(self):
2150         """Get the album's album children.
2151
2152         Returns an iterable returning the children.
2153         """
2154         return self._getChildren(False)
2155
2156
2157     ##############################
2158     # Internal methods.
2159
2160     def _getChildren(self, includeimages):
2161         """Helper method to get the albums children.
2162
2163         Arguments:
2164
2165         includeimages -- Whether images should be included.
2166         """
2167         query = self.getAttribute(u"query")
2168         if not query:
2169             return []
2170         from kofoto import search
2171         parser = search.Parser(self.shelf)
2172         try:
2173             tree = parser.parse(query)
2174         except (AlbumDoesNotExistError,
2175                 CategoryDoesNotExistError,
2176                 search.ParseError):
2177             return []
2178         objects = self.shelf.search(tree)
2179         if includeimages:
2180             objectlist = list(objects)
2181         else:
2182             objectlist = [x for x in objects if x.isAlbum()]
2183
2184         def sortfn(x, y):
2185             """Helper function."""
2186             a = cmp(x.getAttribute(u"captured"), y.getAttribute(u"captured"))
2187             if a == 0:
2188                 return cmp(x.getId(), y.getId())
2189             else:
2190                 return a
2191         objectlist.sort(sortfn)
2192
2193         return objectlist
2194
2195
2196 ######################################################################
2197 ### Internal helper functions and classes.
2198
2199 def _albumTypeIdentifierToType(atid):
2200     """Map an album type identifer string to an AlbumType alternative."""
2201     try:
2202         return {
2203             u"orphans": AlbumType.Orphans,
2204             u"plain": AlbumType.Plain,
2205             u"search": AlbumType.Search,
2206             }[atid]
2207     except KeyError:
2208         raise UnknownAlbumTypeError(atid)
2209
2210
2211 def _albumTypeToIdentifier(atype):
2212     """Map an AlbumType alternative to an album type identifer string."""
2213     try:
2214         return {
2215             AlbumType.Orphans: u"orphans",
2216             AlbumType.Plain: u"plain",
2217             AlbumType.Search: u"search",
2218             }[atype]
2219     except KeyError:
2220         raise UnknownAlbumTypeError(atype)
2221
2222
2223 def _createCategoryDAG(connection):
2224     """Create the category DAG."""
2225     cursor = connection.cursor()
2226     cursor.execute(
2227         " select id"
2228         " from   category")
2229     dag = DAG([x[0] for x in cursor])
2230     cursor.execute(
2231         " select parent, child"
2232         " from   category_child")
2233     for parent, child in cursor:
2234         dag.connect(parent, child)
2235     return dag
2236
2237
2238 def _imageVersionTypeIdentifierToType(ivtype):
2239     """Map an image version type identifer string to an ImageVersionType
2240     alternative.
2241     """
2242     try:
2243         return {
2244             u"important": ImageVersionType.Important,
2245             u"original": ImageVersionType.Original,
2246             u"other": ImageVersionType.Other,
2247             }[ivtype]
2248     except KeyError:
2249         raise UnknownImageVersionTypeError(ivtype)
2250
2251
2252 def _imageVersionTypeToIdentifier(ivtype):
2253     """Map an ImageVersionType alternative to an image version type identifer
2254     string.
2255     """
2256     try:
2257         return {
2258             ImageVersionType.Important: u"important",
2259             ImageVersionType.Original: u"original",
2260             ImageVersionType.Other: u"other",
2261             }[ivtype]
2262     except KeyError:
2263         raise UnknownImageVersionTypeError(ivtype)
2264
2265
2266 class _UnicodeConnectionDecorator:
2267     """A class that makes a database connection Unicode-aware."""
2268
2269     def __init__(self, connection, encoding):
2270         """Constructor.
2271
2272         Arguments:
2273
2274         connection -- The database connection to wrap.
2275         encoding   -- The encoding (e.g. utf-8) to use.
2276         """
2277         self.connection = connection
2278         self.encoding = encoding
2279
2280     def __getattr__(self, attrname):
2281         return getattr(self.connection, attrname)
2282
2283     def cursor(self):
2284         """Return a _UnicodeConnectionDecorator."""
2285         return _UnicodeCursorDecorator(
2286             self.connection.cursor(), self.encoding)
2287
2288
2289 class _UnicodeCursorDecorator:
2290     """A class that makes database cursor Unicode-aware."""
2291
2292     def __init__(self, cursor, encoding):
2293         """Constructor.
2294
2295         Arguments:
2296
2297         cursor   -- The database cursor to wrap.
2298         encoding -- The encoding (e.g. utf-8) to use.
2299         """
2300         self.cursor = cursor
2301         self.encoding = encoding
2302
2303     def __getattr__(self, attrname):
2304         if attrname in ["lastrowid", "rowcount"]:
2305             return getattr(self.cursor, attrname)
2306         else:
2307             raise AttributeError
2308
2309     def __iter__(self):
2310         while True:
2311             rows = self.cursor.fetchmany(17)
2312             if not rows:
2313                 break
2314             for row in rows:
2315                 yield self._unicodifyRow(row)
2316
2317     def _unicodifyRow(self, row):
2318         """Helper method that decodes fields of a row into Unicode."""
2319         result = []
2320         for col in row:
2321             if isinstance(col, str):
2322                 result.append(unicode(col, self.encoding))
2323             else:
2324                 result.append(col)
2325         return result
2326
2327     def _assertUnicode(self, obj):
2328         """Check that all strings in an object are in Unicode.
2329
2330         Lists, tuples and maps are recursively checked.
2331         """
2332         if isinstance(obj, str):
2333             raise AssertionError("non-Unicode string", obj)
2334         elif isinstance(obj, (list, tuple)):
2335             for elem in obj:
2336                 self._assertUnicode(elem)
2337         elif isinstance(obj, dict):
2338             for val in obj.itervalues():
2339                 self._assertUnicode(val)
2340
2341     def execute(self, statement, *parameters):
2342         """Execute an SQL statement.
2343
2344         The SQL string must be Unicode.
2345         """
2346         self._assertUnicode(parameters)
2347         return self.cursor.execute(statement, *parameters)
2348
2349     def fetchone(self):
2350         """Fetch a row from the result set.
2351
2352         The returned row contains Unicode strings.
2353         """
2354         row = self.cursor.fetchone()
2355         if row:
2356             return self._unicodifyRow(row)
2357         else:
2358             return None
2359
2360     def fetchall(self):
2361         """Fetch all row of the result set.
2362
2363         The returned rows contain Unicode strings.
2364         """
2365         return list(self)