Corrected image reloading in SingleObjectView._rowChanged. Fixes ticket #69.
[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             self._freezeViews()
257             iterator = self.__treeModel.insert(location)
258             self.__treeModel.set_value(iterator, self.COLUMN_OBJECT_ID, obj.getId())
259             if obj.isAlbum():
260                 self.__treeModel.set_value(iterator, self.COLUMN_IS_ALBUM, True)
261                 self.__treeModel.set_value(iterator, self.COLUMN_ALBUM_TAG, obj.getTag())
262                 self.__treeModel.set_value(iterator, self.COLUMN_LOCATION, None)
263                 self.__nrOfAlbums += 1
264             else:
265                 self.__treeModel.set_value(iterator, self.COLUMN_IS_ALBUM, False)
266                 self.__treeModel.set_value(iterator, self.COLUMN_ALBUM_TAG, None)
267                 self.__treeModel.set_value(iterator, self.COLUMN_LOCATION, obj.getLocation())
268                 self.__nrOfImages += 1
269                 # TODO Set COLUMN_VALID_LOCATION and COLUMN_VALID_CHECKSUM
270             for attribute, value in obj.getAttributeMap().items():
271                 if "@" + attribute in self.__objectMetadataMap:
272                     column = self.__objectMetadataMap["@" + attribute][self.COLUMN_NR]
273                     self.__treeModel.set_value(iterator, column, value)
274             self.__treeModel.set_value(iterator, self.COLUMN_ROW_EDITABLE, True)
275             self._thawViews()
276             self.signalRowInserted()
277             self.__loadThumbnail(self.__treeModel, iterator)
278             location += 1
279             self.__updateObjectCount(True)
280             yield True
281
282         self._handleNrOfObjectsUpdate()
283         self.__insertionWorkerFinished()
284         yield False
285
286     def __stopInsertionWorker(self):
287         if self.__insertionWorkerTag:
288             gobject.source_remove(self.__insertionWorkerTag)
289             self.__insertionWorkerFinished()
290
291     def __insertionWorkerFinished(self):
292         self.__insertionWorkerTag = None
293         self.__updateObjectCount(False)
294
295     def __updateObjectCount(self, loadingInProgress):
296         env.widgets["statusbarLoadedObjects"].pop(1)
297         if loadingInProgress:
298             text = "%d objects (and counting...)" % len(self.__treeModel)
299         else:
300             text = "%d objects" % len(self.__treeModel)
301         env.widgets["statusbarLoadedObjects"].push(1, text)
302
303     def _handleNrOfObjectsUpdate(self):
304         updatedDisabledFields = Set()
305         if self.__nrOfAlbums == 0:
306             updatedDisabledFields.add(u"albumtag")
307         if self.__nrOfImages == 0:
308             updatedDisabledFields.add(u"location")
309         for view in self.__registeredViews:
310             view.fieldsDisabled(updatedDisabledFields - self.__disabledFields)
311             view.fieldsEnabled(self.__disabledFields - updatedDisabledFields)
312         self.__disabledFields = updatedDisabledFields
313         env.debug("The following fields are disabled: " + str(self.__disabledFields))
314
315     def _getTreeModel(self):
316         return self.__treeModel
317
318     def _freezeViews(self):
319         if self.__frozen:
320             return
321         for view in self.__registeredViews:
322             view.freeze()
323         self.__frozen = True
324
325     def _thawViews(self):
326         if not self.__frozen:
327             return
328         for view in self.__registeredViews:
329             view.thaw()
330         self.__frozen = False
331
332
333 ###############################################################################
334 ### Callback functions
335
336     def _attributeEdited(self, renderer, path, value, column, attributeName):
337         model = self.getModel()
338         columnNumber = self.__objectMetadataMap["@" + attributeName][self.COLUMN_NR]
339         iterator = model.get_iter(path)
340         oldValue = model.get_value(iterator, columnNumber)
341         if not oldValue:
342             oldValue = u""
343         value = unicode(value, "utf-8")
344         if oldValue != value:
345             # TODO Show dialog and ask for confirmation?
346             objectId = model.get_value(iterator, self.COLUMN_OBJECT_ID)
347             obj = env.shelf.getObject(objectId)
348             obj.setAttribute(attributeName, value)
349             model.set_value(iterator, columnNumber, value)
350             env.debug("Object attribute edited")
351
352     def _albumTagEdited(self, renderer, path, value, column, columnNumber):
353         model = self.getModel()
354         iterator = model.get_iter(path)
355         assert model.get_value(iterator, self.COLUMN_IS_ALBUM)
356         oldValue = model.get_value(iterator, columnNumber)
357         if not oldValue:
358             oldValue = u""
359         value = unicode(value, "utf-8")
360         if oldValue != value:
361             # TODO Show dialog and ask for confirmation?
362             objectId = model.get_value(iterator, self.COLUMN_OBJECT_ID)
363             obj = env.shelf.getAlbum(objectId)
364             obj.setTag(value)
365             # TODO Handle invalid album tag?
366             model.set_value(iterator, columnNumber, value)
367             # TODO Update the album tree widget.
368             env.debug("Album tag edited")
369
370     def createAlbumChild(self, *unused):
371         dialog = AlbumDialog("Create album")
372         dialog.run(self._createAlbumChildHelper)
373
374     def _createAlbumChildHelper(self, tag, desc):
375         newAlbum = env.shelf.createAlbum(tag)
376         if len(desc) > 0:
377             newAlbum.setAttribute(u"title", desc)
378         selectedObjects = self.__objectSelection.getSelectedObjects()
379         selectedAlbum = selectedObjects[0]
380         children = list(selectedAlbum.getChildren())
381         children.append(newAlbum)
382         selectedAlbum.setChildren(children)
383         env.mainwindow.reloadAlbumTree()
384
385     def registerAndAddImages(self, *unused):
386         selectedObjects = self.__objectSelection.getSelectedObjects()
387         assert len(selectedObjects) == 1 and selectedObjects[0].isAlbum()
388         selectedAlbum = selectedObjects[0]
389         dialog = RegisterImagesDialog(selectedAlbum)
390         if dialog.run() == gtk.RESPONSE_OK:
391             env.mainwindow.reload() # TODO: Don't reload everything.
392         dialog.destroy()
393
394     def generateHtml(self, *unused):
395         selectedObjects = self.__objectSelection.getSelectedObjects()
396         assert len(selectedObjects) == 1 and selectedObjects[0].isAlbum()
397         selectedAlbum = selectedObjects[0]
398         env.mainwindow.generateHtml(selectedAlbum)
399
400     def albumProperties(self, *unused):
401         selectedObjects = self.__objectSelection.getSelectedObjects()
402         assert len(selectedObjects) == 1 and selectedObjects[0].isAlbum()
403         selectedAlbumId = selectedObjects[0].getId()
404         dialog = AlbumDialog("Edit album", selectedAlbumId)
405         dialog.run(self._albumPropertiesHelper)
406
407     def _albumPropertiesHelper(self, tag, desc):
408         selectedObjects = self.__objectSelection.getSelectedObjects()
409         selectedAlbum = selectedObjects[0]
410         selectedAlbum.setTag(tag)
411         if len(desc) > 0:
412             selectedAlbum.setAttribute(u"title", desc)
413         else:
414             selectedAlbum.deleteAttribute(u"title")
415         env.mainwindow.reloadAlbumTree()
416         # TODO: Update objectCollection.
417
418     def rotateImage(self, widget, angle):
419         env.mainwindow.getImagePreloader().clearCache()
420         for (rowNr, obj) in self.__objectSelection.getMap().items():
421             if not obj.isAlbum():
422                 location = obj.getLocation().encode(env.codeset)
423                 if angle == 90:
424                     commandString = env.rotateRightCommand
425                 else:
426                     commandString = env.rotateLeftCommand
427                 command = commandString.encode(env.codeset) % { "location":location }
428                 result = os.system(command)
429                 if result == 0:
430                     obj.contentChanged()
431                     model = self.getUnsortedModel()
432                     self.__loadThumbnail(model, model.get_iter(rowNr))
433                 else:
434                     dialog = gtk.MessageDialog(
435                         type=gtk.MESSAGE_ERROR,
436                         buttons=gtk.BUTTONS_OK,
437                         message_format="Failed to execute command: \"%s\"" % command)
438                     dialog.run()
439                     dialog.destroy()
440
441     def openImage(self, widget, data):
442         locations = ""
443         for obj in self.__objectSelection.getSelectedObjects():
444             if not obj.isAlbum():
445                 location = obj.getLocation()
446                 locations += location + " "
447         if locations != "":
448             command = env.openCommand % { "locations":locations }
449             # GIMP does not seem to be able to open locations containing swedish
450             # characters. I tried latin-1 and utf-8 without success.
451             result = os.system(command + " &")
452             if result != 0:
453                 dialog = gtk.MessageDialog(
454                     type=gtk.MESSAGE_ERROR,
455                     buttons=gtk.BUTTONS_OK,
456                     message_format="Failed to execute command: \"%s\"" % command)
457                 dialog.run()
458                 dialog.destroy()
459
460 ######################################################################
461 ### Private
462
463     def __addAttribute(self, name):
464         self.__objectMetadataMap["@" + name] = (gobject.TYPE_STRING,
465                                                 len(self.__columnsType),
466                                                 self._attributeEdited,
467                                                 name)
468         self.__columnsType.append(gobject.TYPE_STRING)
469
470     def __loadThumbnail(self, model, iterator):
471         objectId = model.get_value(iterator, self.COLUMN_OBJECT_ID)
472         obj = env.shelf.getObject(objectId)
473         if obj.isAlbum():
474             pixbuf = env.albumIconPixbuf
475         else:
476             try:
477                 thumbnailLocation = env.imageCache.get(
478                     obj, env.thumbnailSize[0], env.thumbnailSize[1])[0]
479                 pixbuf = gtk.gdk.pixbuf_new_from_file(thumbnailLocation.encode(env.codeset))
480                 # TODO Set and use COLUMN_VALID_LOCATION and COLUMN_VALID_CHECKSUM
481             except IOError:
482                 pixbuf = env.unknownImageIconPixbuf
483         model.set_value(iterator, self.COLUMN_THUMBNAIL, pixbuf)