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