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