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