cf16f497ad4db565346e4ab29f34d7b42a0a3324
[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             yield child
1779
1780
1781     def getAlbumChildren(self):
1782         """Get the album's album children.
1783
1784         Returns an iterable returning Album instances.
1785         """
1786         if self.children is not None:
1787             for child in self.children:
1788                 if child.isAlbum():
1789                     yield child
1790             return
1791         cursor = self.shelf._getConnection().cursor()
1792         cursor.execute(
1793             " select member.objectid"
1794             " from   member, album"
1795             " where  member.albumid = %s and"
1796             "        member.objectid = album.albumid"
1797             " order by position",
1798             self.getId())
1799         for (objid,) in cursor:
1800             yield self.shelf.getAlbum(objid)
1801
1802
1803     def setChildren(self, children):
1804         """Set an album's children."""
1805         albumid = self.getId()
1806         cursor = self.shelf._getConnection().cursor()
1807         cursor.execute(
1808             "-- types int")
1809         cursor.execute(
1810             " select count(position)"
1811             " from   member"
1812             " where  albumid = %s",
1813             albumid)
1814         oldchcnt = cursor.fetchone()[0]
1815         newchcnt = len(children)
1816         for ix in range(newchcnt):
1817             childid = children[ix].getId()
1818             if ix < oldchcnt:
1819                 cursor.execute(
1820                     " update member"
1821                     " set    objectid = %s"
1822                     " where  albumid = %s and position = %s",
1823                     childid,
1824                     albumid,
1825                     ix)
1826             else:
1827                 cursor.execute(
1828                     " insert into member (albumid, position, objectid)"
1829                     " values (%s, %s, %s)",
1830                     albumid,
1831                     ix,
1832                     childid)
1833         cursor.execute(
1834             " delete from member"
1835             " where  albumid = %s and position >= %s",
1836             albumid,
1837             newchcnt)
1838         self.shelf._setModified()
1839         self.shelf._setOrphanAlbumsCache(None)
1840         self.shelf._setOrphanImagesCache(None)
1841         self.children = children[:]
1842
1843     ##############################
1844     # Internal methods.
1845
1846     def __init__(self, *args):
1847         """Constructor of an Album."""
1848         Album.__init__(self, *args)
1849         self.children = None
1850
1851
1852 class Image(_Object):
1853     """A Kofoto image."""
1854
1855     ##############################
1856     # Public methods.
1857
1858     def getHash(self):
1859         """Get the hash of the image."""
1860         return self.hash
1861
1862
1863     def getLocation(self):
1864         """Get the last known location of the image."""
1865         return self.location
1866
1867
1868     def getModificationTime(self):
1869         return self.mtime
1870
1871
1872     def getSize(self):
1873         return self.size
1874
1875
1876     def contentChanged(self):
1877         """Record new image information for an edited image.
1878
1879         Checksum, width, height and mtime are updated.
1880
1881         It is assumed that the image location is still correct."""
1882         self.hash = computeImageHash(self.location)
1883         import Image as PILImage
1884         try:
1885             pilimg = PILImage.open(self.location)
1886         except IOError:
1887             raise NotAnImageError, self.location
1888         self.size = pilimg.size
1889         self.mtime = os.path.getmtime(self.location)
1890         cursor = self.shelf._getConnection().cursor()
1891         cursor.execute(
1892             " update image"
1893             " set    hash = %s, width = %s, height = %s, mtime = %s"
1894             " where  imageid = %s",
1895             self.hash,
1896             self.size[0],
1897             self.size[1],
1898             self.mtime,
1899             self.getId())
1900         self.shelf._setModified()
1901
1902
1903     def locationChanged(self, location):
1904         """Set the last known location of the image.
1905
1906         The mtime is also updated."""
1907         cursor = self.shelf._getConnection().cursor()
1908         location = unicode(os.path.realpath(location))
1909         try:
1910             self.mtime = os.path.getmtime(location)
1911         except OSError:
1912             self.mtime = 0
1913         cursor.execute(
1914             " update image"
1915             " set    directory = %s, filename = %s, mtime = %s"
1916             " where  imageid = %s",
1917             os.path.dirname(location),
1918             os.path.basename(location),
1919             self.mtime,
1920             self.getId())
1921         self.location = location
1922         self.shelf._setModified()
1923
1924
1925     def isAlbum(self):
1926         return False
1927
1928
1929     def importExifTags(self):
1930         """Read known EXIF tags and add them as attributes."""
1931         from kofoto import EXIF
1932         tags = EXIF.process_file(
1933             file(self.getLocation().encode(self.shelf.codeset), "rb"))
1934
1935         for tag in ["Image DateTime",
1936                     "EXIF DateTimeOriginal",
1937                     "EXIF DateTimeDigitized"]:
1938             value = tags.get(tag)
1939             if value and str(value) != "0000:00:00 00:00:00":
1940                 m = re.match(
1941                     r"(\d{4})[:/-](\d{2})[:/-](\d{2}) (\d{2}):(\d{2}):(\d{2})",
1942                     str(value))
1943                 if m:
1944                     self.setAttribute(
1945                         u"captured",
1946                         u"%s-%s-%s %s:%s:%s" % m.groups())
1947
1948         value = tags.get("EXIF ExposureTime")
1949         if value:
1950             self.setAttribute(u"exposuretime", unicode(value))
1951         value = tags.get("EXIF FNumber")
1952         if value:
1953             self.setAttribute(u"fnumber", unicode(value))
1954         value = tags.get("EXIF Flash")
1955         if value:
1956             self.setAttribute(u"flash", unicode(value))
1957         value = tags.get("EXIF FocalLength")
1958         if value:
1959             self.setAttribute(u"focallength", unicode(value))
1960         value = tags.get("Image Make")
1961         if value:
1962             self.setAttribute(u"cameramake", unicode(value))
1963         value = tags.get("Image Model")
1964         if value:
1965             self.setAttribute(u"cameramodel", unicode(value))
1966         value = tags.get("Image Orientation")
1967         if value:
1968             try:
1969                 m = {"1": "up",
1970                      "2": "up",
1971                      "3": "down",
1972                      "4": "up",
1973                      "5": "up",
1974                      "6": "left",
1975                      "7": "up",
1976                      "8": "right",
1977                      }
1978                 self.setAttribute(u"orientation", unicode(m[str(value)]))
1979             except KeyError:
1980                 pass
1981         value = tags.get("EXIF ExposureProgram")
1982         if value:
1983             self.setAttribute(u"exposureprogram", unicode(value))
1984         value = tags.get("EXIF ISOSpeedRatings")
1985         if value:
1986             self.setAttribute(u"iso", unicode(value))
1987         value = tags.get("EXIF ExposureBiasValue")
1988         if value:
1989             self.setAttribute(u"exposurebias", unicode(value))
1990         value = tags.get("MakerNote SpecialMode")
1991         if value:
1992             self.setAttribute(u"specialmode", unicode(value))
1993         value = tags.get("MakerNote JPEGQual")
1994         if value:
1995             self.setAttribute(u"jpegquality", unicode(value))
1996         value = tags.get("MakerNote Macro")
1997         if value:
1998             self.setAttribute(u"macro", unicode(value))
1999         value = tags.get("MakerNote DigitalZoom")
2000         if value:
2001             self.setAttribute(u"digitalzoom", unicode(value))
2002
2003     ##############################
2004     # Internal methods.
2005
2006     def __init__(
2007         self, shelf, imageid, imghash, location, mtime, width,
2008         height):
2009         """Constructor of an Image."""
2010         _Object.__init__(self, shelf, imageid)
2011         self.shelf = shelf
2012         self.hash = imghash
2013         self.location = location
2014         self.mtime = mtime
2015         self.size = width, height
2016
2017
2018 class MagicAlbum(Album):
2019     """Base class of magic albums."""
2020
2021     ##############################
2022     # Public methods.
2023
2024     def isMutable(self):
2025         return False
2026
2027
2028     def setChildren(self, children):
2029         raise UnsettableChildrenError, self.getTag()
2030
2031
2032 class AllAlbumsAlbum(MagicAlbum):
2033     """An album with all albums, sorted by tag."""
2034
2035     ##############################
2036     # Public methods.
2037
2038     def getChildren(self):
2039         """Get the album's children.
2040
2041         Returns an iterable returning the albums.
2042         """
2043         albums = self.shelf._getAllAlbumsCache()
2044         if albums != None:
2045             for album in albums:
2046                 yield album
2047         else:
2048             cursor = self.shelf._getConnection().cursor()
2049             cursor.execute(
2050                 " select   albumid"
2051                 " from     album"
2052                 " order by tag")
2053             albums = []
2054             for (albumid,) in cursor:
2055                 album = self.shelf.getAlbum(albumid)
2056                 albums.append(album)
2057                 yield album
2058             self.shelf._setAllAlbumsCache(albums)
2059
2060
2061     def getAlbumChildren(self):
2062         """Get the album's album children.
2063
2064         Returns an iterable returning the albums.
2065         """
2066         return self.getChildren()
2067
2068
2069 class AllImagesAlbum(MagicAlbum):
2070     """An album with all images, sorted by capture timestamp."""
2071
2072     ##############################
2073     # Public methods.
2074
2075     def getChildren(self):
2076         """Get the album's children.
2077
2078         Returns an iterable returning the images.
2079         """
2080         images = self.shelf._getAllImagesCache()
2081         if images != None:
2082             for image in images:
2083                 yield image
2084         else:
2085             cursor = self.shelf._getConnection().cursor()
2086             cursor.execute(
2087                 " select   imageid, hash, directory, filename, mtime,"
2088                 "          width, height"
2089                 " from     image left join attribute"
2090                 " on       imageid = objectid and name = 'captured'"
2091                 " order by lcvalue, directory, filename")
2092             images = []
2093             for (imageid, imghash, directory, filename, mtime, width,
2094                  height) in cursor:
2095                 location = os.path.join(directory, filename)
2096                 image = self.shelf._imageFactory(
2097                     imageid, imghash, location, mtime, width, height)
2098                 images.append(image)
2099                 yield image
2100             self.shelf._setAllImagesCache(images)
2101
2102
2103     def getAlbumChildren(self):
2104         """Get the album's album children.
2105
2106         Returns an iterable returning the images.
2107         """
2108         return []
2109
2110
2111 class OrphansAlbum(MagicAlbum):
2112     """An album with all albums and images that are orphans."""
2113
2114     ##############################
2115     # Public methods.
2116
2117     def getChildren(self):
2118         """Get the album's children.
2119
2120         Returns an iterable returning the orphans.
2121         """
2122         return self._getChildren(True)
2123
2124
2125     def getAlbumChildren(self):
2126         """Get the album's album children.
2127
2128         Returns an iterable returning the orphans.
2129         """
2130         return self._getChildren(False)
2131
2132
2133     ##############################
2134     # Internal methods.
2135
2136     def _getChildren(self, includeimages):
2137         albums = self.shelf._getOrphanAlbumsCache()
2138         if albums != None:
2139             for album in albums:
2140                 yield album
2141         else:
2142             cursor = self.shelf._getConnection().cursor()
2143             cursor.execute(
2144                 " select   albumid"
2145                 " from     album"
2146                 " where    albumid not in (select objectid from member) and"
2147                 "          albumid != %s"
2148                 " order by tag",
2149                 _ROOT_ALBUM_ID)
2150             albums = []
2151             for (albumid,) in cursor:
2152                 album = self.shelf.getAlbum(albumid)
2153                 albums.append(album)
2154                 yield album
2155             self.shelf._setOrphanAlbumsCache(albums)
2156         if includeimages:
2157             images = self.shelf._getOrphanImagesCache()
2158             if images != None:
2159                 for image in images:
2160                     yield image
2161             else:
2162                 cursor = self.shelf._getConnection().cursor()
2163                 cursor.execute(
2164                     " select   imageid, hash, directory, filename, mtime,"
2165                     "          width, height"
2166                     " from     image left join attribute"
2167                     " on       imageid = objectid and name = 'captured'"
2168                     " where    imageid not in (select objectid from member)"
2169                     " order by lcvalue, directory, filename")
2170                 images = []
2171                 for (imageid, imghash, directory, filename, mtime, width,
2172                      height) in cursor:
2173                     location = os.path.join(directory, filename)
2174                     image = self.shelf._imageFactory(
2175                         imageid, imghash, location, mtime, width, height)
2176                     images.append(image)
2177                     yield image
2178                 self.shelf._setOrphanImagesCache(images)
2179
2180
2181 class SearchAlbum(MagicAlbum):
2182     """An album whose content is defined by a search string."""
2183
2184     ##############################
2185     # Public methods.
2186
2187     def getChildren(self):
2188         """Get the album's children.
2189
2190         Returns an iterable returning the children.
2191         """
2192         return self._getChildren(True)
2193
2194
2195     def getAlbumChildren(self):
2196         """Get the album's album children.
2197
2198         Returns an iterable returning the children.
2199         """
2200         return self._getChildren(False)
2201
2202
2203     ##############################
2204     # Internal methods.
2205
2206     def _getChildren(self, includeimages):
2207         query = self.getAttribute(u"query")
2208         if not query:
2209             return []
2210         import kofoto.search
2211         parser = kofoto.search.Parser(self.shelf)
2212         try:
2213             tree = parser.parse(query)
2214         except (AlbumDoesNotExistError,
2215                 CategoryDoesNotExistError,
2216                 kofoto.search.ParseError):
2217             return []
2218         objects = self.shelf.search(tree)
2219         if includeimages:
2220             objectlist = list(objects)
2221         else:
2222             objectlist = [x for x in objects if x.isAlbum()]
2223
2224         def sortfn(x, y):
2225             a = cmp(x.getAttribute(u"captured"), y.getAttribute(u"captured"))
2226             if a == 0:
2227                 return cmp(x.getId(), y.getId())
2228             else:
2229                 return a
2230         objectlist.sort(sortfn)
2231
2232         return objectlist
2233
2234
2235
2236 ######################################################################
2237 ### Internal helper functions and classes.
2238
2239 def _createCategoryDAG(connection):
2240     cursor = connection.cursor()
2241     cursor.execute(
2242         " select categoryid"
2243         " from   category")
2244     dag = DAG([x[0] for x in cursor])
2245     cursor.execute(
2246         " select parent, child"
2247         " from   category_child")
2248     for parent, child in cursor:
2249         dag.connect(parent, child)
2250     return dag
2251
2252
2253 class _UnicodeConnectionDecorator:
2254     def __init__(self, connection, encoding):
2255         self.connection = connection
2256         self.encoding = encoding
2257
2258     def __getattr__(self, attrname):
2259         return getattr(self.connection, attrname)
2260
2261     def cursor(self):
2262         return _UnicodeCursorDecorator(
2263             self.connection.cursor(), self.encoding)
2264
2265
2266 class _UnicodeCursorDecorator:
2267     def __init__(self, cursor, encoding):
2268         self.cursor = cursor
2269         self.encoding = encoding
2270
2271     def __getattr__(self, attrname):
2272         if attrname in ["lastrowid", "rowcount"]:
2273             return getattr(self.cursor, attrname)
2274         else:
2275             raise AttributeError
2276
2277     def __iter__(self):
2278         while True:
2279             rows = self.cursor.fetchmany(17)
2280             if not rows:
2281                 break
2282             for row in rows:
2283                 yield self._unicodifyRow(row)
2284
2285     def _unicodifyRow(self, row):
2286         result = []
2287         for col in row:
2288             if isinstance(col, str):
2289                 result.append(unicode(col, self.encoding))
2290             else:
2291                 result.append(col)
2292         return result
2293
2294     def _assertUnicode(self, obj):
2295         if isinstance(obj, str):
2296             raise AssertionError, ("non-Unicode string", obj)
2297         elif isinstance(obj, (list, tuple)):
2298             for elem in obj:
2299                 self._assertUnicode(elem)
2300         elif isinstance(obj, dict):
2301             for val in obj.itervalues():
2302                 self._assertUnicode(val)
2303
2304     def execute(self, sql, *parameters):
2305         self._assertUnicode(parameters)
2306         return self.cursor.execute(sql, *parameters)
2307
2308     def fetchone(self):
2309         row = self.cursor.fetchone()
2310         if row:
2311             return self._unicodifyRow(row)
2312         else:
2313             return None
2314
2315     def fetchall(self):
2316         return list(self)