0863fb9ac05d4a7f82db6df46d3782dad6a8a69f
[joel/kofoto.git] / src / gkofoto / gkofoto / objectcollection.py
1 import os
2 import gtk
3 import gobject
4 import gc
5 from sets import *
6 from kofoto.shelf import *
7 from menuhandler import *
8 from environment import env
9 from objectselection import *
10 from albumdialog import AlbumDialog
11 from registerimagesdialog import RegisterImagesDialog
12
13 class ObjectCollection(object):
14
15 ######################################################################
16 ### Public
17
18     def __init__(self):
19         env.debug("Init ObjectCollection")
20         self.__objectSelection = ObjectSelection(self)
21         self.__insertionWorkerTag = None
22         self.__registeredViews = []
23         self.__disabledFields = Set()
24         self.__rowInsertedCallbacks = []
25         self.__columnsType = [ gobject.TYPE_BOOLEAN,  # COLUMN_VALID_LOCATION
26                                gobject.TYPE_BOOLEAN,  # COLUMN_VALID_CHECKSUM
27                                gobject.TYPE_BOOLEAN,  # COLUMN_ROW_EDITABLE
28                                gobject.TYPE_BOOLEAN,  # COLUMN_IS_ALBUM
29                                gobject.TYPE_INT,      # COLUMN_OBJECT_ID
30                                gobject.TYPE_STRING,   # COLUMN_LOCATION
31                                gtk.gdk.Pixbuf,        # COLUMN_THUMBNAIL
32                                gobject.TYPE_STRING ]  # COLUMN_ALBUM_TAG
33         self.__objectMetadataMap = {
34             u"id"       :(gobject.TYPE_INT,    self.COLUMN_OBJECT_ID, None,                 None),
35             u"location" :(gobject.TYPE_STRING, self.COLUMN_LOCATION,  None,                 None),
36             u"thumbnail":(gtk.gdk.Pixbuf,      self.COLUMN_THUMBNAIL, None,                 None),
37             u"albumtag" :(gobject.TYPE_STRING, self.COLUMN_ALBUM_TAG, self._albumTagEdited, self.COLUMN_ALBUM_TAG) }
38         for name in env.shelf.getAllAttributeNames():
39             self.__addAttribute(name)
40         self.__treeModel = gtk.ListStore(*self.__columnsType)
41         self.__frozen = False
42
43     # Return true if the objects has a defined order and may
44     # be reordered. An object that is reorderable is not
45     # allowed to also be sortable.
46     def isReorderable(self):
47         return False
48
49     # Return true if the objects may be sorted.
50     def isSortable(self):
51         return False
52
53     # Return true if objects may be added and removed from the collection.
54     def isMutable(self):
55         return not self.isLoading()
56
57     # Return true if object collection has not finished loading.
58     def isLoading(self):
59         return self.__insertionWorkerTag != None
60
61     def getCutLabel(self):
62         return "Cut reference"
63
64     def getCopyLabel(self):
65         return "Copy reference"
66
67     def getPasteLabel(self):
68         return "Paste reference"
69
70     def getDeleteLabel(self):
71         return "Delete reference"
72
73     def getDestroyLabel(self):
74         return "Destroy..."
75
76     def getCreateAlbumChildLabel(self):
77         return "Create album child..."
78
79     def getRegisterImagesLabel(self):
80         return "Register and add images..."
81
82     def getGenerateHtmlLabel(self):
83         return "Generate HTML..."
84
85     def getAlbumPropertiesLabel(self):
86         return "Album properties..."
87
88     def getOpenImageLabel(self):
89         return "Open image in external program..."
90
91     def getRotateImageLeftLabel(self):
92         return "Rotate image left"
93
94     def getRotateImageRightLabel(self):
95         return "Rotate image right"
96
97     def getObjectMetadataMap(self):
98         return self.__objectMetadataMap
99
100     def getModel(self):
101         return self.__treeModel
102
103     def getUnsortedModel(self):
104         return self.__treeModel
105
106     def addInsertedRowCallback(self, callback, data=None):
107         self.__rowInsertedCallbacks.append((callback, data))
108
109     def removeInsertedRowCallback(self, callback, data=None):
110         self.__rowInsertedCallbacks.remove((callback, data))
111
112     def signalRowInserted(self):
113         for callback, data in self.__rowInsertedCallbacks:
114             callback(data)
115
116     def convertToUnsortedRowNr(self, rowNr):
117         return rowNr
118
119     def convertFromUnsortedRowNr(self, unsortedRowNr):
120         return unsortedRowNr
121
122     def getObjectSelection(self):
123         return self.__objectSelection
124
125     def getDisabledFields(self):
126         return self.__disabledFields
127
128     def registerView(self, view):
129         env.debug("Register view to object collection")
130         self.__registeredViews.append(view)
131
132     def unRegisterView(self, view):
133         env.debug("Unregister view from object collection")
134         self.__registeredViews.remove(view)
135
136     def clear(self, freeze=True):
137         env.debug("Clearing object collection")
138         if freeze:
139             self._freezeViews()
140         self.__stopInsertionWorker()
141         self.__treeModel.clear()
142         gc.collect()
143         self.__nrOfAlbums = 0
144         self.__nrOfImages = 0
145         self._handleNrOfObjectsUpdate()
146         self.__objectSelection.unselectAll()
147         if freeze:
148             self._thawViews()
149
150     def cut(self, *foo):
151         raise Exception("Error. Not allowed to cut objects into objectCollection.") # TODO
152
153     def copy(self, *foo):
154         env.clipboard.setObjects(self.__objectSelection.getSelectedObjects())
155
156     def paste(self, *foo):
157         raise Exception("Error. Not allowed to paste objects into objectCollection.") # TODO
158
159     def delete(self, *foo):
160         raise Exception("Error. Not allowed to delete objects from objectCollection.") # TODO
161
162     def destroy(self, *foo):
163         model = self.getModel()
164
165         albumsSelected = False
166         imagesSelected = False
167         for position in self.__objectSelection:
168             iterator = model.get_iter(position)
169             isAlbum = model.get_value(
170                 iterator, self.COLUMN_IS_ALBUM)
171             if isAlbum:
172                 albumsSelected = True
173             else:
174                 imagesSelected = True
175
176         assert albumsSelected ^ imagesSelected
177
178         self._freezeViews()
179         if albumsSelected:
180             dialogId = "destroyAlbumsDialog"
181         else:
182             dialogId = "destroyImagesDialog"
183         widgets = gtk.glade.XML(env.gladeFile, dialogId)
184         dialog = widgets.get_widget(dialogId)
185         result = dialog.run()
186         if result == gtk.RESPONSE_OK:
187             if albumsSelected:
188                 deleteFiles = False
189             else:
190                 checkbutton = widgets.get_widget("deleteImageFilesCheckbutton")
191                 deleteFiles = checkbutton.get_active()
192             for obj in self.__objectSelection.getSelectedObjects():
193                 if deleteFiles:
194                     try:
195                         os.remove(obj.getLocation())
196                         # TODO: Delete from image cache too?
197                     except OSError:
198                         pass
199                 env.shelf.deleteObject(obj.getId())
200             locations = list(self.getObjectSelection())
201             locations.sort()
202             locations.reverse()
203             for loc in locations:
204                 del model[loc]
205             self.getObjectSelection().unselectAll()
206         dialog.destroy()
207         # TODO: If the removed objects are albums, update the album widget.
208         self._thawViews()
209
210     COLUMN_VALID_LOCATION = 0
211     COLUMN_VALID_CHECKSUM = 1
212     COLUMN_ROW_EDITABLE   = 2
213     COLUMN_IS_ALBUM       = 3
214
215     # Columns visible to user
216     COLUMN_OBJECT_ID      = 4
217     COLUMN_LOCATION       = 5
218     COLUMN_THUMBNAIL      = 6
219     COLUMN_ALBUM_TAG      = 7
220
221     # Content in objectMetadata fields
222     TYPE                 = 0
223     COLUMN_NR            = 1
224     EDITED_CALLBACK      = 2
225     EDITED_CALLBACK_DATA = 3
226
227
228
229 ######################################################################
230 ### Only for subbclasses
231
232     def _getRegisteredViews(self):
233         return self.__registeredViews
234
235     def _loadObjectList(self, objectList):
236         env.enter("Object collection loading objects.")
237         self._freezeViews()
238         self.clear(False)
239         self._insertObjectList(objectList)
240         self._thawViews()
241         env.exit("Object collection loading objects. (albums=" + str(self.__nrOfAlbums) + " images=" + str(self.__nrOfImages) + ")")
242
243     def _insertObjectList(self, objectList, location=None):
244         # location = None means insert last, otherwise insert before
245         # location.
246         #
247         # Note that this method does NOT update objectSelection.
248
249         if location == None:
250             location = len(self.__treeModel)
251         self.__insertionWorkerTag = gobject.idle_add(
252             self.__insertionWorker(objectList, location).next)
253
254     def __insertionWorker(self, objectList, location):
255         for obj in objectList:
256             iterator = self.__treeModel.insert(location)
257             self.__treeModel.set_value(iterator, self.COLUMN_OBJECT_ID, obj.getId())
258             if obj.isAlbum():
259                 self.__treeModel.set_value(iterator, self.COLUMN_IS_ALBUM, True)
260                 self.__treeModel.set_value(iterator, self.COLUMN_ALBUM_TAG, obj.getTag())
261                 self.__treeModel.set_value(iterator, self.COLUMN_LOCATION, None)
262                 self.__nrOfAlbums += 1
263             else:
264                 self.__treeModel.set_value(iterator, self.COLUMN_IS_ALBUM, False)
265                 self.__treeModel.set_value(iterator, self.COLUMN_ALBUM_TAG, None)
266                 self.__treeModel.set_value(iterator, self.COLUMN_LOCATION, obj.getLocation())
267                 self.__nrOfImages += 1
268                 # TODO Set COLUMN_VALID_LOCATION and COLUMN_VALID_CHECKSUM
269             for attribute, value in obj.getAttributeMap().items():
270                 if "@" + attribute in self.__objectMetadataMap:
271                     column = self.__objectMetadataMap["@" + attribute][self.COLUMN_NR]
272                     self.__treeModel.set_value(iterator, column, value)
273             self.__treeModel.set_value(iterator, self.COLUMN_ROW_EDITABLE, True)
274             self.signalRowInserted()
275             self.__loadThumbnail(self.__treeModel, iterator)
276             location += 1
277             self.__updateObjectCount(True)
278             yield True
279
280         self._handleNrOfObjectsUpdate()
281         self.__insertionWorkerFinished()
282         yield False
283
284     def __stopInsertionWorker(self):
285         if self.__insertionWorkerTag:
286             gobject.source_remove(self.__insertionWorkerTag)
287             self.__insertionWorkerFinished()
288
289     def __insertionWorkerFinished(self):
290         self.__insertionWorkerTag = None
291         self.__updateObjectCount(False)
292
293     def __updateObjectCount(self, loadingInProgress):
294         env.widgets["statusbarLoadedObjects"].pop(1)
295         if loadingInProgress:
296             text = "%d objects (and counting...)" % len(self.__treeModel)
297         else:
298             text = "%d objects" % len(self.__treeModel)
299         env.widgets["statusbarLoadedObjects"].push(1, text)
300
301     def _handleNrOfObjectsUpdate(self):
302         updatedDisabledFields = Set()
303         if self.__nrOfAlbums == 0:
304             updatedDisabledFields.add(u"albumtag")
305         if self.__nrOfImages == 0:
306             updatedDisabledFields.add(u"location")
307         for view in self.__registeredViews:
308             view.fieldsDisabled(updatedDisabledFields - self.__disabledFields)
309             view.fieldsEnabled(self.__disabledFields - updatedDisabledFields)
310         self.__disabledFields = updatedDisabledFields
311         env.debug("The following fields are disabled: " + str(self.__disabledFields))
312
313     def _getTreeModel(self):
314         return self.__treeModel
315
316     def _freezeViews(self):
317         if self.__frozen:
318             return
319         for view in self.__registeredViews:
320             view.freeze()
321         self.__frozen = True
322
323     def _thawViews(self):
324         if not self.__frozen:
325             return
326         for view in self.__registeredViews:
327             view.thaw()
328         self.__frozen = False
329
330
331 ###############################################################################
332 ### Callback functions
333
334     def _attributeEdited(self, renderer, path, value, column, attributeName):
335         model = self.getModel()
336         columnNumber = self.__objectMetadataMap["@" + attributeName][self.COLUMN_NR]
337         iterator = model.get_iter(path)
338         oldValue = model.get_value(iterator, columnNumber)
339         if not oldValue:
340             oldValue = u""
341         value = unicode(value, "utf-8")
342         if oldValue != value:
343             # TODO Show dialog and ask for confirmation?
344             objectId = model.get_value(iterator, self.COLUMN_OBJECT_ID)
345             obj = env.shelf.getObject(objectId)
346             obj.setAttribute(attributeName, value)
347             model.set_value(iterator, columnNumber, value)
348             env.debug("Object attribute edited")
349
350     def _albumTagEdited(self, renderer, path, value, column, columnNumber):
351         model = self.getModel()
352         iterator = model.get_iter(path)
353         assert model.get_value(iterator, self.COLUMN_IS_ALBUM)
354         oldValue = model.get_value(iterator, columnNumber)
355         if not oldValue:
356             oldValue = u""
357         value = unicode(value, "utf-8")
358         if oldValue != value:
359             # TODO Show dialog and ask for confirmation?
360             objectId = model.get_value(iterator, self.COLUMN_OBJECT_ID)
361             obj = env.shelf.getAlbum(objectId)
362             obj.setTag(value)
363             # TODO Handle invalid album tag?
364             model.set_value(iterator, columnNumber, value)
365             # TODO Update the album tree widget.
366             env.debug("Album tag edited")
367
368     def createAlbumChild(self, *unused):
369         dialog = AlbumDialog("Create album")
370         dialog.run(self._createAlbumChildHelper)
371
372     def _createAlbumChildHelper(self, tag, desc):
373         newAlbum = env.shelf.createAlbum(tag)
374         if len(desc) > 0:
375             newAlbum.setAttribute(u"title", desc)
376         selectedObjects = self.__objectSelection.getSelectedObjects()
377         selectedAlbum = selectedObjects[0]
378         children = list(selectedAlbum.getChildren())
379         children.append(newAlbum)
380         selectedAlbum.setChildren(children)
381         env.mainwindow.reloadAlbumTree()
382
383     def registerAndAddImages(self, *unused):
384         selectedObjects = self.__objectSelection.getSelectedObjects()
385         assert len(selectedObjects) == 1 and selectedObjects[0].isAlbum()
386         selectedAlbum = selectedObjects[0]
387         dialog = RegisterImagesDialog(selectedAlbum)
388         if dialog.run() == gtk.RESPONSE_OK:
389             env.mainwindow.reload() # TODO: Don't reload everything.
390         dialog.destroy()
391
392     def generateHtml(self, *unused):
393         selectedObjects = self.__objectSelection.getSelectedObjects()
394         assert len(selectedObjects) == 1 and selectedObjects[0].isAlbum()
395         selectedAlbum = selectedObjects[0]
396         env.mainwindow.generateHtml(selectedAlbum)
397
398     def albumProperties(self, *unused):
399         selectedObjects = self.__objectSelection.getSelectedObjects()
400         assert len(selectedObjects) == 1 and selectedObjects[0].isAlbum()
401         selectedAlbumId = selectedObjects[0].getId()
402         dialog = AlbumDialog("Edit album", selectedAlbumId)
403         dialog.run(self._albumPropertiesHelper)
404
405     def _albumPropertiesHelper(self, tag, desc):
406         selectedObjects = self.__objectSelection.getSelectedObjects()
407         selectedAlbum = selectedObjects[0]
408         selectedAlbum.setTag(tag)
409         if len(desc) > 0:
410             selectedAlbum.setAttribute(u"title", desc)
411         else:
412             selectedAlbum.deleteAttribute(u"title")
413         env.mainwindow.reloadAlbumTree()
414         # TODO: Update objectCollection.
415
416     def rotateImage(self, widget, angle):
417         env.mainwindow.getImagePreloader().clearCache()
418         for (rowNr, obj) in self.__objectSelection.getMap().items():
419             if not obj.isAlbum():
420                 location = obj.getLocation().encode(env.codeset)
421                 if angle == 90:
422                     commandString = env.rotateRightCommand
423                 else:
424                     commandString = env.rotateLeftCommand
425                 command = commandString.encode(env.codeset) % { "location":location }
426                 result = os.system(command)
427                 if result == 0:
428                     obj.contentChanged()
429                     model = self.getUnsortedModel()
430                     self.__loadThumbnail(model, model.get_iter(rowNr))
431                 else:
432                     dialog = gtk.MessageDialog(
433                         type=gtk.MESSAGE_ERROR,
434                         buttons=gtk.BUTTONS_OK,
435                         message_format="Failed to execute command: \"%s\"" % command)
436                     dialog.run()
437                     dialog.destroy()
438
439     def openImage(self, widget, data):
440         locations = ""
441         for obj in self.__objectSelection.getSelectedObjects():
442             if not obj.isAlbum():
443                 location = obj.getLocation()
444                 locations += location + " "
445         if locations != "":
446             command = env.openCommand % { "locations":locations }
447             # GIMP does not seem to be able to open locations containing swedish
448             # characters. I tried latin-1 and utf-8 without success.
449             result = os.system(command + " &")
450             if result != 0:
451                 dialog = gtk.MessageDialog(
452                     type=gtk.MESSAGE_ERROR,
453                     buttons=gtk.BUTTONS_OK,
454                     message_format="Failed to execute command: \"%s\"" % command)
455                 dialog.run()
456                 dialog.destroy()
457
458 ######################################################################
459 ### Private
460
461     def __addAttribute(self, name):
462         self.__objectMetadataMap["@" + name] = (gobject.TYPE_STRING,
463                                                 len(self.__columnsType),
464                                                 self._attributeEdited,
465                                                 name)
466         self.__columnsType.append(gobject.TYPE_STRING)
467
468     def __loadThumbnail(self, model, iterator):
469         objectId = model.get_value(iterator, self.COLUMN_OBJECT_ID)
470         obj = env.shelf.getObject(objectId)
471         if obj.isAlbum():
472             pixbuf = env.albumIconPixbuf
473         else:
474             try:
475                 thumbnailLocation = env.imageCache.get(
476                     obj, env.thumbnailSize[0], env.thumbnailSize[1])[0]
477                 pixbuf = gtk.gdk.pixbuf_new_from_file(thumbnailLocation.encode(env.codeset))
478                 # TODO Set and use COLUMN_VALID_LOCATION and COLUMN_VALID_CHECKSUM
479             except IOError:
480                 pixbuf = env.unknownImageIconPixbuf
481         model.set_value(iterator, self.COLUMN_THUMBNAIL, pixbuf)