More fixes for modern versions of the sqlite 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 = sql.connect(self.location)
161         except sql.DatabaseError:
162             raise FailedWritingError(self.location)
163         self._createShelf()
164
165
166     def isUpgradable(self):
167         """Check whether the database format is upgradable.
168
169         This method must currently be called outside a transaction.
170
171         If this method returns True, run Shelf.tryUpgrade.
172         """
173         assert not self.inTransaction
174         return shelfupgrade.isUpgradable(self.location)
175
176
177     def tryUpgrade(self):
178         """Try to upgrade the database to a newer format.
179
180         This method must currently be called outside a transaction.
181
182         Returns True if upgrade was successful, otherwise False.
183         """
184         assert not self.inTransaction
185         return shelfupgrade.tryUpgrade(self.location, _SHELF_FORMAT_VERSION)
186
187
188     def begin(self):
189         """Begin working with the shelf."""
190         assert not self.inTransaction
191         self.transactionLock.acquire()
192         self.inTransaction = True
193         if not os.path.exists(self.location):
194             raise ShelfNotFoundError(self.location)
195         try:
196             self.connection = sql.connect(self.location)
197             cursor = self.connection.execute("BEGIN EXCLUSIVE")
198         except sql.OperationalError:
199             raise ShelfLockedError(self.location)
200         except sql.DatabaseError:
201             raise ShelfNotFoundError(self.location)
202         self.categorydag = CachedObject(_createCategoryDAG, (self.connection,))
203         try:
204             self._openShelf() # Starts the SQLite transaction.
205         except:
206             self.inTransaction = False
207             self.transactionLock.release()
208             raise
209
210
211     def commit(self):
212         """Commit the work on the shelf."""
213         assert self.inTransaction
214         try:
215             self.connection.commit()
216         finally:
217             self.flushCategoryCache()
218             self.flushObjectCache()
219             self.flushImageVersionCache()
220             self._unsetModified()
221             self.inTransaction = False
222             self.transactionLock.release()
223
224
225     def rollback(self):
226         """Abort the work on the shelf.
227
228         The changes (if any) will not be saved."""
229         assert self.inTransaction
230         try:
231             self.connection.rollback()
232         finally:
233             self.flushCategoryCache()
234             self.flushObjectCache()
235             self.flushImageVersionCache()
236             self._unsetModified()
237             self.inTransaction = False
238             self.transactionLock.release()
239
240
241     def isModified(self):
242         """Check whether the shelf has uncommited changes."""
243         assert self.inTransaction
244         return self.modified
245
246
247     def registerModificationCallback(self, callback):
248         """Register a function that will be called when the
249         modification status changes.
250
251         The function will receive a single argument: True if the shelf
252         has been modified, otherwise False. """
253         self.modificationCallbacks.append(callback)
254
255
256     def unregisterModificationCallback(self, callback):
257         """Unregister a modification callback function."""
258         try:
259             self.modificationCallbacks.remove(callback)
260         except ValueError:
261             pass
262
263
264     def flushCategoryCache(self):
265         """Flush the category cache."""
266         assert self.inTransaction
267         self.categorydag.invalidate()
268         self.categorycache = {}
269
270
271     def flushObjectCache(self):
272         """Flush the object cache."""
273         assert self.inTransaction
274         self.objectcache = {}
275         self.orphanAlbumsCache = None
276         self.orphanImagesCache = None
277
278
279     def flushImageVersionCache(self):
280         """Flush the image version cache."""
281         assert self.inTransaction
282         self.imageversioncache = {}
283
284
285     def getStatistics(self):
286         """Get statistics about the metadata database.
287
288         The returned value is a mapping with the following keys:
289
290         nalbums        -- Number of albums.
291         ncategories    -- Number of categories.
292         nimages        -- Number of images.
293         nimageversions -- Number of image versions.
294         """
295         assert self.inTransaction
296         cursor = self.connection.cursor()
297         cursor.execute(
298             " select count(*)"
299             " from   album")
300         nalbums = int(cursor.fetchone()[0])
301         cursor.execute(
302             " select count(*)"
303             " from   category")
304         ncategories = int(cursor.fetchone()[0])
305         cursor.execute(
306             " select count(*)"
307             " from   image")
308         nimages = int(cursor.fetchone()[0])
309         cursor.execute(
310             " select count(*)"
311             " from   image_version")
312         nimageversions = int(cursor.fetchone()[0])
313         return {
314             "nalbums": nalbums,
315             "ncategories": ncategories,
316             "nimages": nimages,
317             "nimageversions": nimageversions,
318             }
319
320
321     def createAlbum(self, tag, albumtype=AlbumType.Plain):
322         """Create an empty, orphaned album.
323
324         Returns an Album instance."""
325         assert self.inTransaction
326         verifyValidAlbumTag(tag)
327         cursor = self.connection.cursor()
328         try:
329             cursor.execute(
330                 " insert into object (id)"
331                 " values (null)")
332             lastrowid = cursor.lastrowid
333             cursor.execute(
334                 " insert into album (id, tag, deletable, type)"
335                 " values (?, ?, 1, ?)",
336                 (lastrowid, tag, _albumTypeToIdentifier(albumtype)))
337             self._setModified()
338             self.orphanAlbumsCache = None
339             return self.getAlbum(lastrowid)
340         except sql.IntegrityError:
341             cursor.execute(
342                 " delete from object"
343                 " where id = ?",
344                 (cursor.lastrowid,))
345             raise AlbumExistsError(tag)
346
347
348     def getAlbum(self, albumid):
349         """Get the album for a given album ID.
350
351         Returns an Album instance.
352         """
353         assert self.inTransaction
354         if albumid in self.objectcache:
355             album = self.objectcache[albumid]
356             if not album.isAlbum():
357                 raise AlbumDoesNotExistError(albumid)
358             return album
359         cursor = self.connection.cursor()
360         cursor.execute(
361             " select id, tag, type"
362             " from   album"
363             " where  id = ?",
364             (albumid,))
365         row = cursor.fetchone()
366         if not row:
367             raise AlbumDoesNotExistError(albumid)
368         albumid, tag, albumtype = row
369         albumtype = _albumTypeIdentifierToType(albumtype)
370         album = self._albumFactory(albumid, tag, albumtype)
371         return album
372
373
374     def getAlbumByTag(self, tag):
375         """Get the album for a given album tag.
376
377         Returns an Album instance.
378         """
379         assert self.inTransaction
380         cursor = self.connection.cursor()
381         cursor.execute(
382             " select id"
383             " from   album"
384             " where  tag = ?",
385             (tag,))
386         row = cursor.fetchone()
387         if not row:
388             raise AlbumDoesNotExistError(tag)
389         return self.getAlbum(int(row[0]))
390
391
392     def getRootAlbum(self):
393         """Get the root album.
394
395         Returns an Album instance.
396         """
397         assert self.inTransaction
398         return self.getAlbum(_ROOT_ALBUM_ID)
399
400
401     def getAllAlbums(self):
402         """Get all albums in the shelf (unsorted).
403
404         Returns an iterable returning the albums."""
405         assert self.inTransaction
406         cursor = self.connection.cursor()
407         cursor.execute(
408             " select id, tag, type"
409             " from   album")
410         for albumid, tag, albumtype in cursor:
411             if albumid in self.objectcache:
412                 yield self.objectcache[albumid]
413             else:
414                 albumtype = _albumTypeIdentifierToType(albumtype)
415                 yield self._albumFactory(albumid, tag, albumtype)
416
417
418     def getAllImages(self):
419         """Get all images in the shelf (unsorted).
420
421         Returns an iterable returning the images."""
422         assert self.inTransaction
423         cursor = self.connection.cursor()
424         cursor.execute(
425             " select id, primary_version"
426             " from   image")
427         for (imageid, primary_version_id) in cursor:
428             if imageid in self.objectcache:
429                 yield self.objectcache[imageid]
430             else:
431                 yield self._imageFactory(imageid, primary_version_id)
432
433
434     def getAllImageVersions(self):
435         """Get all image versions in the shelf (unsorted).
436
437         Returns an iterable returning the image versions."""
438         assert self.inTransaction
439         cursor = self.connection.cursor()
440         cursor.execute(
441             " select id, image, type, hash, directory, filename, mtime,"
442             "        width, height, comment"
443             " from   image_version")
444         for (ivid, imageid, ivtype, ivhash, directory,
445              filename, mtime, width, height, comment) in cursor:
446             location = os.path.join(directory, filename)
447             if ivid in self.imageversioncache:
448                 yield self.imageversioncache[ivid]
449             else:
450                 ivtype = _imageVersionTypeIdentifierToType(ivtype)
451                 yield self._imageVersionFactory(
452                     ivid, imageid, ivtype, ivhash, location, mtime,
453                     width, height, comment)
454
455
456     def getImageVersionsInDirectory(self, directory):
457         """Get all image versions that are expected to be in a given
458         directory (unsorted).
459
460         Returns an iterable returning the image versions."""
461         assert self.inTransaction
462         directory = unicode(os.path.realpath(directory))
463         cursor = self.connection.cursor()
464         cursor.execute(
465             " select id, image, type, hash, directory, filename, mtime,"
466             "        width, height, comment"
467             " from   image_version"
468             " where  directory = ?",
469             (directory,))
470         for (ivid, imageid, ivtype, ivhash, directory, filename,
471              mtime, width, height, comment) in cursor:
472             location = os.path.join(directory, filename)
473             if ivid in self.imageversioncache:
474                 yield self.imageversioncache[ivid]
475             else:
476                 ivtype = _imageVersionTypeIdentifierToType(ivtype)
477                 yield self._imageVersionFactory(
478                     ivid, imageid, ivtype, ivhash, location, mtime,
479                     width, height, comment)
480
481
482     def deleteAlbum(self, albumid):
483         """Delete an album."""
484         assert self.inTransaction
485         cursor = self.connection.cursor()
486         cursor.execute(
487             " select id, tag"
488             " from   album"
489             " where  id = ?",
490             (albumid,))
491         row = cursor.fetchone()
492         if not row:
493             raise AlbumDoesNotExistError(albumid)
494         albumid, tag = row
495         if albumid == _ROOT_ALBUM_ID:
496             # Don't delete the root album!
497             raise UndeletableAlbumError(tag)
498         cursor.execute(
499             " delete from album"
500             " where  id = ?",
501             (albumid,))
502         self._deleteObjectFromParents(albumid)
503         cursor.execute(
504             " delete from member"
505             " where  album = ?",
506             (albumid,))
507         cursor.execute(
508             " delete from object"
509             " where  id = ?",
510             (albumid,))
511         cursor.execute(
512             " delete from attribute"
513             " where  object = ?",
514             (albumid,))
515         cursor.execute(
516             " delete from object_category"
517             " where  object = ?",
518             (albumid,))
519         if albumid in self.objectcache:
520             del self.objectcache[albumid]
521         self._setModified()
522         self.orphanAlbumsCache = None
523
524
525     def createImage(self):
526         """Create a new, orphaned image.
527
528         Returns an Image instance."""
529         assert self.inTransaction
530         cursor = self.connection.cursor()
531         cursor.execute(
532             " insert into object (id)"
533             " values (null)")
534         imageid = cursor.lastrowid
535         cursor.execute(
536             " insert into image (id, primary_version)"
537             " values (?, NULL)",
538             (imageid,))
539         self._setModified()
540         self.orphanImagesCache = None
541         return self.getImage(imageid)
542
543
544     def getImage(self, imageid):
545         """Get the image for a given ID.
546
547         Returns an Image instance.
548         """
549         assert self.inTransaction
550         if imageid in self.objectcache:
551             image = self.objectcache[imageid]
552             if image.isAlbum():
553                 raise ImageDoesNotExistError(imageid)
554             return image
555         cursor = self.connection.cursor()
556         cursor.execute(
557             " select id, primary_version"
558             " from   image"
559             " where  id = ?",
560             (imageid,))
561         row = cursor.fetchone()
562         if not row:
563             raise ImageDoesNotExistError(imageid)
564         imageid, primary_version_id = row
565         image = self._imageFactory(imageid, primary_version_id)
566         return image
567
568
569     def createImageVersion(self, image, location, ivtype):
570         """Create a new image version.
571
572         Returns an ImageVersion instance."""
573         assert ivtype in ImageVersionType
574         assert self.inTransaction
575         import Image as PILImage
576         try:
577             pilimg = PILImage.open(location)
578             if not pilimg.mode in ("L", "RGB", "CMYK"):
579                 pilimg = pilimg.convert("RGB")
580 #        except IOError:
581         except: # Work-around for buggy PIL.
582             raise NotAnImageFileError(location)
583         width, height = pilimg.size
584         location = os.path.realpath(location)
585         mtime = os.path.getmtime(location)
586         ivhash = computeImageHash(location)
587         cursor = self.connection.cursor()
588         try:
589             cursor.execute(
590                 " insert into image_version"
591                 "     (image, type, hash, directory, filename,"
592                 "      mtime, width, height, comment)"
593                 " values"
594                 "     (?, ?, ?, ?, ?, ?, ?, ?, '')",
595                 (image.getId(),
596                  _imageVersionTypeToIdentifier(ivtype),
597                  ivhash,
598                  os.path.dirname(location),
599                  os.path.basename(location),
600                  mtime,
601                  width,
602                  height))
603         except sql.IntegrityError:
604             raise ImageVersionExistsError(location)
605         ivid = cursor.lastrowid
606         imageversion = self._imageVersionFactory(
607             ivid, image.getId(), ivtype, ivhash, location, mtime,
608             width, height, u"")
609         try:
610             imageversion.importExifTags(False)
611         except ExifImportError:
612             # Ignore exceptions from buggy EXIF library for now.
613             pass
614         if image.getPrimaryVersion() == None:
615             image._makeNewPrimaryVersion()
616         self._setModified()
617         return imageversion
618
619
620     def getImageVersion(self, ivid):
621         """Get the image version for a given ID.
622
623         Returns an ImageVersion instance.
624         """
625         assert self.inTransaction
626
627         if ivid in self.imageversioncache:
628             return self.imageversioncache[ivid]
629
630         cursor = self.connection.cursor()
631         cursor.execute(
632             " select id, image, type, hash, directory, filename, mtime,"
633             "        width, height, comment"
634             " from   image_version"
635             " where  id = ?",
636             (ivid,))
637         row = cursor.fetchone()
638         if not row:
639             raise ImageVersionDoesNotExistError(ivid)
640         ivid, imageid, ivtype, ivhash, directory, filename, mtime, \
641             width, height, comment = row
642         location = os.path.join(directory, filename)
643         ivtype = _imageVersionTypeIdentifierToType(ivtype)
644         return self._imageVersionFactory(
645             ivid, imageid, ivtype, ivhash, location, mtime,
646             width, height, comment)
647
648
649     def getImageVersionByHash(self, ivhash):
650         """Get the image version for a given hash.
651
652         Returns an ImageVersion instance.
653         """
654         assert self.inTransaction
655
656         cursor = self.connection.cursor()
657         cursor.execute(
658             " select id"
659             " from   image_version"
660             " where  hash = ?",
661             (ivhash,))
662         row = cursor.fetchone()
663         if not row:
664             raise ImageVersionDoesNotExistError(ivhash)
665         return self.getImageVersion(row[0])
666
667
668     def getImageVersionByLocation(self, location):
669         """Get the image version for a given location.
670
671         Note, though, that an image location is not required to be
672         unique in the shelf; if several image versions have the same
673         location, MultipleImageVersionsAtOneLocationError is raised.
674
675         Returns an ImageVersion instance.
676         """
677         assert self.inTransaction
678
679         location = os.path.abspath(location)
680         cursor = self.connection.cursor()
681         cursor.execute(
682             " select id"
683             " from   image_version"
684             " where  directory = ? and filename = ?",
685             (os.path.dirname(location), os.path.basename(location)))
686         rows = cursor.fetchall()
687         if len(rows) > 1:
688             raise MultipleImageVersionsAtOneLocationError(location)
689         if not rows:
690             raise ImageVersionDoesNotExistError(location)
691         return self.getImageVersion(rows[0][0])
692
693
694     def deleteImage(self, imageid):
695         """Delete an image."""
696         assert self.inTransaction
697
698         cursor = self.connection.cursor()
699         cursor.execute(
700             " select 1"
701             " from   image"
702             " where  id = ?",
703             (imageid,))
704         rows = cursor.fetchall()
705         if not rows:
706             raise ImageDoesNotExistError(imageid)
707         cursor.execute(
708             " select id"
709             " from   image_version"
710             " where  image = ?",
711             (imageid,))
712         for (ivid,) in cursor:
713             if ivid in self.imageversioncache:
714                 del self.imageversioncache[ivid]
715         cursor.execute(
716             " delete from image_version"
717             " where  image = ?",
718             (imageid,))
719         cursor.execute(
720             " delete from image"
721             " where  id = ?",
722             (imageid,))
723         self._deleteObjectFromParents(imageid)
724         cursor.execute(
725             " delete from object"
726             " where  id = ?",
727             (imageid,))
728         cursor.execute(
729             " delete from attribute"
730             " where  object = ?",
731             (imageid,))
732         cursor.execute(
733             " delete from object_category"
734             " where  object = ?",
735             (imageid,))
736         if imageid in self.objectcache:
737             del self.objectcache[imageid]
738         self._setModified()
739         self.orphanImagesCache = None
740
741
742     def deleteImageVersion(self, ivid):
743         """Delete an image version."""
744         assert self.inTransaction
745
746         image = self.getImageVersion(ivid).getImage()
747         primary_version_id = image.getPrimaryVersion().getId()
748         cursor = self.connection.cursor()
749         cursor.execute(
750             " delete from image_version"
751             " where  id = ?",
752             (ivid,))
753         if primary_version_id == ivid:
754             image._makeNewPrimaryVersion()
755         if ivid in self.imageversioncache:
756             del self.imageversioncache[ivid]
757         self._setModified()
758
759
760     def getObject(self, objid):
761         """Get the object for a given object ID."""
762         assert self.inTransaction
763         if objid in self.objectcache:
764             return self.objectcache[objid]
765         try:
766             return self.getImage(objid)
767         except ImageDoesNotExistError:
768             try:
769                 return self.getAlbum(objid)
770             except AlbumDoesNotExistError:
771                 raise ObjectDoesNotExistError(objid)
772
773
774     def deleteObject(self, objid):
775         """Get the object for a given object ID."""
776         assert self.inTransaction
777         try:
778             self.deleteImage(objid)
779         except ImageDoesNotExistError:
780             try:
781                 self.deleteAlbum(objid)
782             except AlbumDoesNotExistError:
783                 raise ObjectDoesNotExistError(objid)
784
785
786     def getAllAttributeNames(self):
787         """Get all used attribute names in the shelf (sorted).
788
789         Returns an iterable returning the attribute names."""
790         assert self.inTransaction
791         cursor = self.connection.cursor()
792         cursor.execute(
793             " select distinct name"
794             " from   attribute"
795             " order by name")
796         for (name,) in cursor:
797             yield name
798
799
800     def createCategory(self, tag, desc):
801         """Create a category.
802
803         Returns a Category instance."""
804         assert self.inTransaction
805         verifyValidCategoryTag(tag)
806         try:
807             cursor = self.connection.cursor()
808             cursor.execute(
809                 " insert into category (tag, description)"
810                 " values (?, ?)",
811                 (tag, desc))
812             self.categorydag.get().add(cursor.lastrowid)
813             self._setModified()
814             return self.getCategory(cursor.lastrowid)
815         except sql.IntegrityError:
816             raise CategoryExistsError(tag)
817
818
819     def deleteCategory(self, catid):
820         """Delete a category for a given category ID."""
821         assert self.inTransaction
822
823         cursor = self.connection.cursor()
824         cursor.execute(
825             " select tag"
826             " from   category"
827             " where  id = ?",
828             (catid,))
829         row = cursor.fetchone()
830         if not row:
831             raise CategoryDoesNotExistError(catid)
832         cursor.execute(
833             " delete from category_child"
834             " where  parent = ?",
835             (catid,))
836         cursor.execute(
837             " delete from category_child"
838             " where  child = ?",
839             (catid,))
840         cursor.execute(
841             " select object from object_category"
842             " where  category = ?",
843             (catid,))
844         for (objectid,) in cursor:
845             if objectid in self.objectcache:
846                 self.objectcache[objectid]._categoriesDirty()
847         cursor.execute(
848             " delete from object_category"
849             " where  category = ?",
850             (catid,))
851         cursor.execute(
852             " delete from category"
853             " where  id = ?",
854             (catid,))
855         catdag = self.categorydag.get()
856         if catid in catdag:
857             catdag.remove(catid)
858         if catid in self.categorycache:
859             del self.categorycache[catid]
860         self._setModified()
861
862
863     def getCategory(self, catid):
864         """Get a category for a given category tag/ID.
865
866         Returns a Category instance."""
867         assert self.inTransaction
868
869         if catid in self.categorycache:
870             return self.categorycache[catid]
871         cursor = self.connection.cursor()
872         cursor.execute(
873             " select tag, description"
874             " from   category"
875             " where  id = ?",
876             (catid,))
877         row = cursor.fetchone()
878         if not row:
879             raise CategoryDoesNotExistError(catid)
880         tag, desc = row
881         category = Category(self, catid, tag, desc)
882         self.categorycache[catid] = category
883         return category
884
885
886     def getCategoryByTag(self, tag):
887         """Get a category for a given category tag.
888
889         Returns a Category instance."""
890         assert self.inTransaction
891
892         cursor = self.connection.cursor()
893         cursor.execute(
894             " select id"
895             " from   category"
896             " where  tag = ?",
897             (tag,))
898         row = cursor.fetchone()
899         if not row:
900             raise CategoryDoesNotExistError(tag)
901         return self.getCategory(row[0])
902
903
904     def getRootCategories(self):
905         """Get the categories that are roots, i.e. have no parents.
906
907         Returns an iterable returning Category instances."""
908         assert self.inTransaction
909         for catid in self.categorydag.get().getRoots():
910             yield self.getCategory(catid)
911
912
913     def getMatchingCategories(self, regexp):
914         """Get the categories that case insensitively match a given
915         compiled regexp object.
916
917         Returns an iterable returning Category instances."""
918         assert self.inTransaction
919         for catid in self.categorydag.get():
920             category = self.getCategory(catid)
921             if (regexp.match(category.getTag().lower()) or
922                 regexp.match(category.getDescription().lower())):
923                 yield category
924
925
926     def search(self, searchtree):
927         """Search for objects matching a search node tree.
928
929         Use kofoto.search.Parser to construct a search node tree from
930         a string.
931
932         Returns an iterable returning the objects."""
933         assert self.inTransaction
934         cursor = self.connection.cursor()
935         cursor.execute(searchtree.getQuery())
936         for (objid,) in cursor:
937             yield self.getObject(objid)
938
939     ##############################
940     # Internal methods.
941
942     def _createShelf(self):
943         """Helper method for Shelf.create."""
944         cursor = self.connection.cursor()
945         cursor.executescript(shelfschema.schema)
946         cursor.execute(
947             " insert into dbinfo (version)"
948             " values (?)",
949             (_SHELF_FORMAT_VERSION,))
950         cursor.execute(
951             " insert into object (id)"
952             " values (?)",
953             (_ROOT_ALBUM_ID,))
954         cursor.execute(
955             " insert into album (id, tag, deletable, type)"
956             " values (?, ?, 0, 'plain')",
957             (_ROOT_ALBUM_ID, u"root"))
958         self.connection.commit()
959
960         self.begin()
961         rootalbum = self.getRootAlbum()
962         rootalbum.setAttribute(u"title", u"Root album")
963         orphansalbum = self.createAlbum(u"orphans", AlbumType.Orphans)
964         orphansalbum.setAttribute(u"title", u"Orphans")
965         orphansalbum.setAttribute(
966             u"description",
967             u"This album contains albums and images that are not" +
968             u" linked from any album.")
969         self.getRootAlbum().setChildren([orphansalbum])
970         self.createCategory(u"events", u"Events")
971         self.createCategory(u"locations", u"Locations")
972         self.createCategory(u"people", u"People")
973         self.commit()
974
975
976     def _openShelf(self):
977         """Helper method for Shelf.open."""
978         cursor = self.connection.cursor()
979         try:
980             cursor.execute(
981                 " select version"
982                 " from   dbinfo")
983         except sql.OperationalError, e:
984             if e.message == "database is locked":
985                 raise ShelfLockedError(self.location)
986             else:
987                 raise UnsupportedShelfError(self.location)
988         version = cursor.fetchone()[0]
989         if version != _SHELF_FORMAT_VERSION:
990             raise UnsupportedShelfError(self.location)
991
992
993     def _albumFactory(self, albumid, tag, albumtype):
994         """Factory method for creating Album instances.
995
996         Arguments:
997
998         albumid   -- ID of the album.
999         tag       -- Tag of the album.
1000         albumtype -- An instance of the AlbumType alternative.
1001         """
1002         albumtypemap = {
1003             AlbumType.Orphans: OrphansAlbum,
1004             AlbumType.Plain: PlainAlbum,
1005             AlbumType.Search: SearchAlbum,
1006         }
1007         album = albumtypemap[albumtype](self, albumid, tag, albumtype)
1008         self.objectcache[albumid] = album
1009         return album
1010
1011
1012     def _imageFactory(self, imageid, primary_version_id):
1013         """Factory method for creating Image instances.
1014
1015         Arguments:
1016
1017         imageid            -- ID of the image.
1018         primary_version_id -- ID of the primary image version.
1019         """
1020         image = Image(self, imageid, primary_version_id)
1021         self.objectcache[imageid] = image
1022         return image
1023
1024
1025     def _imageVersionFactory(self, ivid, imageid, ivtype, ivhash,
1026                              location, mtime, width, height, comment):
1027         """Factory method for creating ImageVersion instances.
1028
1029         Arguments:
1030
1031         ivid     -- ID of the image version.
1032         imageid  -- ID of the image the image version belongs to.
1033         ivtype   -- An instance of the ImageVersionType alternative.
1034         ivhash   -- Hash of the image version file.
1035         location -- Location of the image version file.
1036         mtime    -- mtime of the image version file.
1037         width    -- Width of the image version.
1038         height   -- Height of the image version.
1039         comment  -- Comment of the image version.
1040         """
1041         imageversion = ImageVersion(
1042             self, ivid, imageid, ivtype, ivhash, location, mtime, width,
1043             height, comment)
1044         self.imageversioncache[ivid] = imageversion
1045         return imageversion
1046
1047
1048     def _deleteObjectFromParents(self, objid):
1049         """Helper method that deletes an object from its parents."""
1050         cursor = self.connection.cursor()
1051         cursor.execute(
1052             " select distinct album.id, album.tag"
1053             " from   member, album"
1054             " where  member.object = ? and member.album = album.id",
1055             (objid,))
1056         parentinfolist = cursor.fetchall()
1057         for parentid, _ in parentinfolist:
1058             cursor.execute(
1059                 " select position"
1060                 " from   member"
1061                 " where  album = ? and object = ?"
1062                 " order by position desc",
1063                 (parentid, objid))
1064             positions = [x[0] for x in cursor.fetchall()]
1065             for position in positions:
1066                 cursor.execute(
1067                     " delete from member"
1068                     " where  album = ? and position = ?",
1069                     (parentid, position))
1070                 cursor.execute(
1071                     " update member"
1072                     " set    position = position - 1"
1073                     " where  album = ? and position > ?",
1074                     (parentid, position))
1075             if parentid in self.objectcache:
1076                 del self.objectcache[parentid]
1077
1078
1079     def _setModified(self):
1080         """Set the modified flag."""
1081         self.modified = True
1082         for fn in self.modificationCallbacks:
1083             fn(True)
1084
1085
1086     def _unsetModified(self):
1087         """Unset the modified flag."""
1088         self.modified = False
1089         for fn in self.modificationCallbacks:
1090             fn(False)
1091
1092
1093     def _getConnection(self):
1094         """Get the database connection instance."""
1095         assert self.inTransaction
1096         return self.connection
1097
1098
1099     def _getOrphanAlbumsCache(self):
1100         """Get the cache of the orphaned albums."""
1101         assert self.inTransaction
1102         return self.orphanAlbumsCache
1103
1104
1105     def _setOrphanAlbumsCache(self, albums):
1106         """Set the cache of the orphaned albums."""
1107         assert self.inTransaction
1108         self.orphanAlbumsCache = albums
1109
1110
1111     def _getOrphanImagesCache(self):
1112         """Get the cache of the orphaned images."""
1113         assert self.inTransaction
1114         return self.orphanImagesCache
1115
1116
1117     def _setOrphanImagesCache(self, images):
1118         """Set the cache of the orphaned images."""
1119         assert self.inTransaction
1120         self.orphanImagesCache = images
1121
1122
1123 class Category:
1124     """A Kofoto category."""
1125
1126     ##############################
1127     # Public methods.
1128
1129     def getId(self):
1130         """Get category ID."""
1131         return self.catid
1132
1133
1134     def getTag(self):
1135         """Get category tag."""
1136         return self.tag
1137
1138
1139     def setTag(self, newtag):
1140         """Set category tag."""
1141         verifyValidCategoryTag(newtag)
1142         cursor = self.shelf._getConnection().cursor()
1143         cursor.execute(
1144             " update category"
1145             " set    tag = ?"
1146             " where  id = ?",
1147             (newtag, self.getId()))
1148         self.tag = newtag
1149         self.shelf._setModified()
1150
1151
1152     def getDescription(self):
1153         """Get category description."""
1154         return self.description
1155
1156
1157     def setDescription(self, newdesc):
1158         """Set category description."""
1159         cursor = self.shelf._getConnection().cursor()
1160         cursor.execute(
1161             " update category"
1162             " set    description = ?"
1163             " where  id = ?",
1164             (newdesc, self.getId()))
1165         self.description = newdesc
1166         self.shelf._setModified()
1167
1168
1169     def getChildren(self, recursive=False):
1170         """Get child categories.
1171
1172         If recursive is true, get all descendants. If recursive is
1173         false, get only immediate children. Returns an iterable
1174         returning of Category instances (unordered)."""
1175         catdag = self.shelf.categorydag.get()
1176         if recursive:
1177             catiter = catdag.getDescendants(self.getId())
1178         else:
1179             catiter = catdag.getChildren(self.getId())
1180         for catid in catiter:
1181             yield self.shelf.getCategory(catid)
1182
1183
1184     def getParents(self, recursive=False):
1185         """Get parent categories.
1186
1187         If recursive is true, get all ancestors. If recursive is
1188         false, get only immediate parents. Returns an iterable
1189         returning of Category instances (unordered)."""
1190         catdag = self.shelf.categorydag.get()
1191         if recursive:
1192             catiter = catdag.getAncestors(self.getId())
1193         else:
1194             catiter = catdag.getParents(self.getId())
1195         for catid in catiter:
1196             yield self.shelf.getCategory(catid)
1197
1198
1199     def isChildOf(self, category, recursive=False):
1200         """Check whether this category is a child or descendant of a
1201         category.
1202
1203         If recursive is true, check if the category is a descendant of
1204         this category, otherwise just consider immediate children."""
1205         parentid = category.getId()
1206         childid = self.getId()
1207         catdag = self.shelf.categorydag.get()
1208         if recursive:
1209             return catdag.reachable(parentid, childid)
1210         else:
1211             return catdag.connected(parentid, childid)
1212
1213
1214     def isParentOf(self, category, recursive=False):
1215         """Check whether this category is a parent or ancestor of a
1216         category.
1217
1218         If recursive is true, check if the category is an ancestor of
1219         this category, otherwise just consider immediate parents."""
1220         return category.isChildOf(self, recursive)
1221
1222
1223     def connectChild(self, category):
1224         """Make parent-child link between this category and a category."""
1225         parentid = self.getId()
1226         childid = category.getId()
1227         if self.shelf.categorydag.get().connected(parentid, childid):
1228             raise CategoriesAlreadyConnectedError(
1229                 self.getTag(), category.getTag())
1230         try:
1231             self.shelf.categorydag.get().connect(parentid, childid)
1232         except LoopError:
1233             raise CategoryLoopError(self.getTag(), category.getTag())
1234         cursor = self.shelf._getConnection().cursor()
1235         cursor.execute(
1236             " insert into category_child (parent, child)"
1237             " values (?, ?)",
1238             (parentid, childid))
1239         self.shelf._setModified()
1240
1241
1242     def disconnectChild(self, category):
1243         """Remove a parent-child link between this category and a category."""
1244         parentid = self.getId()
1245         childid = category.getId()
1246         self.shelf.categorydag.get().disconnect(parentid, childid)
1247         cursor = self.shelf._getConnection().cursor()
1248         cursor.execute(
1249             " delete from category_child"
1250             " where  parent = ? and child = ?",
1251             (parentid, childid))
1252         self.shelf._setModified()
1253
1254
1255     ##############################
1256     # Internal methods.
1257
1258     def __init__(self, shelf, catid, tag, description):
1259         self.shelf = shelf
1260         self.catid = catid
1261         self.tag = tag
1262         self.description = description
1263
1264
1265     def __eq__(self, obj):
1266         return isinstance(obj, Category) and obj.getId() == self.getId()
1267
1268
1269     def __ne__(self, obj):
1270         return not obj == self
1271
1272
1273     def __hash__(self):
1274         return self.getId()
1275
1276
1277 class _Object:
1278     """Abstract base class of Kofoto objects (albums and images)."""
1279
1280     ##############################
1281     # Public methods.
1282
1283     def isAlbum(self):
1284         """Return True if this an album, False if this is an image."""
1285         raise NotImplementedError
1286
1287     def getId(self):
1288         """Get the ID of an object."""
1289         return self.objid
1290
1291
1292     def getParents(self):
1293         """Get the parent albums of an object.
1294
1295         Returns an iterable returning the albums.
1296
1297         Note that the object may be included multiple times in a
1298         parent album."""
1299         cursor = self.shelf._getConnection().cursor()
1300         cursor.execute(
1301             " select distinct album.id"
1302             " from   member, album"
1303             " where  member.object = ? and"
1304             "        member.album = album.id",
1305             (self.getId(),))
1306         for (albumid,) in cursor:
1307             yield self.shelf.getAlbum(albumid)
1308
1309
1310     def getAttribute(self, name):
1311         """Get the value of an attribute.
1312
1313         Returns the value as string, or None if there was no matching
1314         attribute.
1315         """
1316         if name in self.attributes:
1317             return self.attributes[name]
1318         cursor = self.shelf._getConnection().cursor()
1319         cursor.execute(
1320             " select value"
1321             " from   attribute"
1322             " where  object = ? and name = ?",
1323             (self.getId(), name))
1324         rows = cursor.fetchall()
1325         if rows:
1326             value = rows[0][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 == 1:
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)