def sync(self, lists): self.diskChanges = ChangeMonitor() self.conflictChanges = ChangeMonitor() self.notifier = AbstractNotifier.getSimple() self.memMap = dict() self.memOwnerMap = dict() self.diskMap = dict() self.diskOwnerMap = dict() for devGUID, changes in self._allChanges.items(): if devGUID == self._monitor.guid(): self.diskChanges = changes break self._allChanges[self._monitor.guid()] = self._monitor for memList, diskList in lists: self.mergeObjects(memList, diskList) # Cleanup monitor self._monitor.empty() for memList, diskList in lists: for obj in self.allObjects(memList.rootItems()): self._monitor.resetChanges(obj) # Merge conflict changes for devGUID, changes in self._allChanges.items(): if devGUID != self._monitor.guid(): changes.merge(self.conflictChanges)
def setUp(self): self.monitor = ChangeMonitor() self.monitor.monitorClass(self.klass) self.list = self.listClass() self.monitor.monitorCollection(self.list) self.obj = self.klass(subject=u'New object') self.list.append(self.obj)
def read(self): allChanges = dict() tree = ET.parse(self.__fd) for devNode in tree.getroot().findall('device'): id_ = devNode.attrib['guid'] mon = ChangeMonitor(id_) for objNode in devNode.findall('obj'): if objNode.text: changes = set(objNode.text.split(',')) else: changes = set() mon.setChanges(objNode.attrib['id'], changes) allChanges[id_] = mon return allChanges
def setUp(self): self.monitor = ChangeMonitor() self.monitor.monitorClass(CategorizableCompositeObject) self.obj = CategorizableCompositeObject(subject=u'Object') self.list = ObservableList() self.monitor.monitorCollection(self.list) self.list.append(self.obj) self.cat1 = Category(subject=u'Cat #1') self.cat2 = Category(subject=u'Cat #2') self.catList = ObservableList() self.catList.append(self.cat1) self.catList.append(self.cat2)
def read(self): allChanges = dict() tree = ET.parse(self.__fd) for devNode in tree.getroot().findall("device"): id_ = devNode.attrib["guid"] mon = ChangeMonitor(id_) for objNode in devNode.findall("obj"): if objNode.text: changes = set(objNode.text.split(",")) else: changes = set() mon.setChanges(objNode.attrib["id"], changes) allChanges[id_] = mon return allChanges
def __init__(self, *args, **kwargs): self.__filename = self.__lastFilename = '' self.__needSave = self.__loading = False self.__tasks = task.TaskList() self.__categories = category.CategoryList() self.__notes = note.NoteContainer() self.__efforts = effort.EffortList(self.tasks()) self.__guid = generate() self.__syncMLConfig = createDefaultSyncConfig(self.__guid) self.__monitor = ChangeMonitor() self.__changes = dict() self.__changes[self.__monitor.guid()] = self.__monitor self.__changedOnDisk = False if kwargs.pop('poll', True): self.__notifier = TaskCoachFilesystemPollerNotifier(self) else: self.__notifier = TaskCoachFilesystemNotifier(self) self.__saving = False for collection in [self.__tasks, self.__categories, self.__notes]: self.__monitor.monitorCollection(collection) for domainClass in [task.Task, category.Category, note.Note, effort.Effort, attachment.FileAttachment, attachment.URIAttachment, attachment.MailAttachment]: self.__monitor.monitorClass(domainClass) super(TaskFile, self).__init__(*args, **kwargs) # Register for tasks, categories, efforts and notes being changed so we # can monitor when the task file needs saving (i.e. is 'dirty'): for container in self.tasks(), self.categories(), self.notes(): for eventType in container.modificationEventTypes(): self.registerObserver(self.onDomainObjectAddedOrRemoved, eventType, eventSource=container) for eventType in (base.Object.markDeletedEventType(), base.Object.markNotDeletedEventType()): self.registerObserver(self.onDomainObjectAddedOrRemoved, eventType) for eventType in task.Task.modificationEventTypes(): if not eventType.startswith('pubsub'): self.registerObserver(self.onTaskChanged_Deprecated, eventType) pub.subscribe(self.onTaskChanged, 'pubsub.task') for eventType in effort.Effort.modificationEventTypes(): self.registerObserver(self.onEffortChanged, eventType) for eventType in note.Note.modificationEventTypes(): if not eventType.startswith('pubsub'): self.registerObserver(self.onNoteChanged_Deprecated, eventType) pub.subscribe(self.onNoteChanged, 'pubsub.note') for eventType in category.Category.modificationEventTypes(): if not eventType.startswith('pubsub'): self.registerObserver(self.onCategoryChanged_Deprecated, eventType) pub.subscribe(self.onCategoryChanged, 'pubsub.category') for eventType in attachment.FileAttachment.modificationEventTypes() + \ attachment.URIAttachment.modificationEventTypes() + \ attachment.MailAttachment.modificationEventTypes(): if not eventType.startswith('pubsub'): self.registerObserver(self.onAttachmentChanged_Deprecated, eventType) pub.subscribe(self.onAttachmentChanged, 'pubsub.attachment')
class MonitorBaseTest(test.TestCase): klass = Object listClass = ObservableList def setUp(self): self.monitor = ChangeMonitor() self.monitor.monitorClass(self.klass) self.list = self.listClass() self.monitor.monitorCollection(self.list) self.obj = self.klass(subject=u'New object') self.list.append(self.obj) def tearDown(self): self.monitor.unmonitorClass(self.klass) self.monitor.unmonitorCollection(self.list)
class MonitorCategorizableTest(test.TestCase): def setUp(self): self.monitor = ChangeMonitor() self.monitor.monitorClass(CategorizableCompositeObject) self.obj = CategorizableCompositeObject(subject=u'Object') self.list = ObservableList() self.monitor.monitorCollection(self.list) self.list.append(self.obj) self.cat1 = Category(subject=u'Cat #1') self.cat2 = Category(subject=u'Cat #2') self.catList = ObservableList() self.catList.append(self.cat1) self.catList.append(self.cat2) def tearDown(self): self.monitor.unmonitorClass(CategorizableCompositeObject) self.monitor.unmonitorCollection(self.list) def testAddCategory(self): self.monitor.resetAllChanges() self.obj.addCategory(self.cat1) self.assertEqual(self.monitor.getChanges(self.obj), set(['__add_category:%s' % self.cat1.id()])) def testRemoveCategory(self): self.obj.addCategory(self.cat1) self.monitor.resetAllChanges() self.obj.removeCategory(self.cat1) self.assertEqual(self.monitor.getChanges(self.obj), set(['__del_category:%s' % self.cat1.id()])) def testRemoveBadCategory(self): self.obj.addCategory(self.cat1) self.monitor.resetAllChanges() self.obj.removeCategory(self.cat2) self.assertEqual(self.monitor.getChanges(self.obj), set())
class ChangeSynchronizer(object): def __init__(self, monitor, allChanges): self._monitor = monitor self._allChanges = allChanges @staticmethod def allObjects(theList): result = list() for obj in theList: result.append(obj) if isinstance(obj, CompositeObject): result.extend(ChangeSynchronizer.allObjects(obj.children())) if isinstance(obj, NoteOwner): result.extend(ChangeSynchronizer.allObjects(obj.notes())) if isinstance(obj, AttachmentOwner): result.extend(ChangeSynchronizer.allObjects(obj.attachments())) if isinstance(obj, Task): result.extend(obj.efforts()) return result def sync(self, lists): self.diskChanges = ChangeMonitor() self.conflictChanges = ChangeMonitor() self.notifier = AbstractNotifier.getSimple() self.memMap = dict() self.memOwnerMap = dict() self.diskMap = dict() self.diskOwnerMap = dict() for devGUID, changes in self._allChanges.items(): if devGUID == self._monitor.guid(): self.diskChanges = changes break self._allChanges[self._monitor.guid()] = self._monitor for memList, diskList in lists: self.mergeObjects(memList, diskList) # Cleanup monitor self._monitor.empty() for memList, diskList in lists: for obj in self.allObjects(memList.rootItems()): self._monitor.resetChanges(obj) # Merge conflict changes for devGUID, changes in self._allChanges.items(): if devGUID != self._monitor.guid(): changes.merge(self.conflictChanges) def notify(self, message): self.notifier.notify(_('Task Coach'), message, wx.ArtProvider.GetBitmap('taskcoach', size=wx.Size(32, 32))) def mergeObjects(self, memList, diskList): # Map id to object def addIds(objects, idMap, ownerMap, owner=None): for obj in objects: idMap[obj.id()] = obj if owner is not None: ownerMap[obj.id()] = owner if isinstance(obj, CompositeObject): addIds(obj.children(), idMap, ownerMap) if isinstance(obj, NoteOwner): addIds(obj.notes(), idMap, ownerMap, obj) if isinstance(obj, AttachmentOwner): addIds(obj.attachments(), idMap, ownerMap, obj) if isinstance(obj, Task): addIds(obj.efforts(), idMap, ownerMap) addIds(memList, self.memMap, self.memOwnerMap) addIds(diskList, self.diskMap, self.diskOwnerMap) self.mergeCompositeObjects(memList, diskList) self.mergeOwnedObjectsFromDisk(diskList) self.reparentObjects(memList, diskList) self.deletedObjects(memList) self.deletedOwnedObjects(memList) self.applyChanges(memList) def mergeCompositeObjects(self, memList, diskList): # First pass: new composite objects on disk. Don't handle # other (notes belonging to object, attachments, efforts) yet # because they may belong to a category. This assumes that # categories are the first domain class handled. for diskObject in diskList.allItemsSorted(): memChanges = self._monitor.getChanges(diskObject) deleted = memChanges is not None and '__del__' in memChanges diskChanges = self.diskChanges.getChanges(diskObject) if deleted and diskChanges is not None and '__del__' not in diskChanges and len(diskChanges) > 0: # "undelete" it memChanges.remove('__del__') deleted = False if diskObject.id() not in self.memMap and not deleted: if isinstance(diskObject, CompositeObject): # New children will be handled later. This assumes # that the parent() is not changed when removing a # child. for child in diskObject.children(): diskObject.removeChild(child) parent = diskObject.parent() if parent is not None and parent.id() in self.memMap: parent = self.memMap[parent.id()] parent.addChild(diskObject) diskObject.setParent(parent) elif parent is not None: # Parent deleted from memory; the task will be # top-level. diskObject.setParent(None) self.conflictChanges.addChange(diskObject, '__parent__') self.notify(_('"%s" became top-level because its parent was locally deleted.') % diskObject.subject()) memList.append(diskObject) self.memMap[diskObject.id()] = diskObject def mergeOwnedObjectsFromDisk(self, diskList): # Second pass: '******' objects (notes and attachments # currently) new on disk, and efforts. for obj in diskList.allItemsSorted(): if isinstance(obj, NoteOwner): self._handleNewOwnedObjectsOnDisk(obj.notes()) if isinstance(obj, AttachmentOwner): self._handleNewOwnedObjectsOnDisk(obj.attachments()) if isinstance(obj, Task): self._handleNewEffortsOnDisk(obj.efforts()) def _handleNewOwnedObjectsOnDisk(self, diskObjects): for diskObject in diskObjects: className = diskObject.__class__.__name__ if className.endswith('Attachment'): className = 'Attachment' if isinstance(diskObject, CompositeObject): children = diskObject.children()[:] memChanges = self._monitor.getChanges(diskObject) deleted = memChanges is not None and '__del__' in memChanges if diskObject.id() not in self.memMap and not deleted: addObject = True if isinstance(diskObject, CompositeObject): for child in diskObject.children(): diskObject.removeChild(child) parent = diskObject.parent() if parent is not None and parent.id() in self.memMap: parent = self.memMap[parent.id()] parent.addChild(diskObject) diskObject.setParent(parent) elif parent is not None: # Parent deleted from memory; the object # becomes top-level but its owner stays # the same. diskObject.setParent(None) while parent.parent() is not None: parent = parent.parent() diskOwner = self.diskOwnerMap[parent.id()] if diskOwner.id() in self.memMap: memOwner = self.memMap[diskOwner.id()] getattr(memOwner, 'add%s' % className)(diskObject) self.conflictChanges.addChange(diskObject, '__owner__') self.memOwnerMap[diskObject.id()] = memOwner self.notify(_('"%s" became top-level because its parent was locally deleted.') % diskObject.subject()) else: diskOwner = self.diskOwnerMap[diskObject.id()] if diskOwner.id() in self.memMap: memOwner = self.memMap[diskOwner.id()] getattr(memOwner, 'add%s' % className)(diskObject) self.memOwnerMap[diskObject.id()] = memOwner else: # Owner deleted. Just forget it. self.conflictChanges.addChange(diskObject, '__del__') addObject = False else: diskOwner = self.diskOwnerMap[diskObject.id()] if diskOwner.id() in self.memMap: memOwner = self.memMap[diskOwner.id()] getattr(memOwner, 'add%s' % className)(diskObject) self.memOwnerMap[diskObject.id()] = memOwner else: # Forget it again... self.conflictChanges.addChange(diskObject, '__del__') addObject = False if addObject: self.memMap[diskObject.id()] = diskObject if diskObject.id() in self.memMap: if isinstance(diskObject, CompositeObject): self._handleNewOwnedObjectsOnDisk(children) if isinstance(diskObject, NoteOwner): self._handleNewOwnedObjectsOnDisk(diskObject.notes()) if isinstance(diskObject, AttachmentOwner): self._handleNewOwnedObjectsOnDisk(diskObject.attachments()) def _handleNewEffortsOnDisk(self, diskEfforts): for diskEffort in diskEfforts: memChanges = self._monitor.getChanges(diskEffort) deleted = memChanges is not None and '__del__' in memChanges if diskEffort.id() not in self.memMap and not deleted: diskTask = diskEffort.parent() if diskTask.id() in self.memMap: memTask = self.memMap[diskTask.id()] diskEffort.setTask(memTask) self.memMap[diskEffort.id()] = diskEffort else: # Task deleted; forget it. self.conflictChanges.addChange(diskEffort, '__del__') def reparentObjects(self, memList, diskList): # Third pass: objects reparented on disk. for diskObject in self.allObjects(diskList): diskChanges = self.diskChanges.getChanges(diskObject) if diskChanges is not None and '__parent__' in diskChanges: memChanges = self._monitor.getChanges(diskObject) memObject = self.memMap[diskObject.id()] memList.remove(memObject) # Note: no conflict resolution for this one, # it would be a bit tricky... Instead, the # disk version wins. def sameParents(a, b): if a is None and b is None: return True elif a is None or b is None: return False return a.id() == b.id() parentConflict = False if memChanges is not None and '__parent__' in memChanges: if not sameParents(memObject.parent(), diskObject.parent()): parentConflict = True if memObject.parent() is not None: memObject.parent().removeChild(memObject) if parentConflict: diskParent = diskObject.parent() if diskParent is None: memObject.setParent(None) else: if diskParent.id() in self.memMap: memParent = self.memMap[diskParent.id()] memParent.addChild(memObject) memObject.setParent(memParent) else: # New parent deleted from memory... memObject.setParent(None) self.conflictChanges.addChange(memObject, '__parent__') else: diskParent = diskObject.parent() if diskParent is None: memObject.setParent(None) else: if diskParent.id() in self.memMap: memParent = self.memMap[diskParent.id()] memParent.addChild(memObject) memObject.setParent(memParent) else: memObject.setParent(None) self.conflictChanges.addChange(memObject, '__parent__') memList.append(memObject) def deletedObjects(self, memList): # Fourth pass: objects deleted from disk for memObject in memList.allItemsSorted(): diskChanges = self.diskChanges.getChanges(memObject) memChanges = self._monitor.getChanges(memObject) if diskChanges is not None and '__del__' in diskChanges: if (memChanges is None or '__del__' in memChanges or len(memChanges) == 0): memList.remove(memObject) del self.memMap[memObject.id()] if memObject.id() in self.memOwnerMap: del self.memOwnerMap[memObject.id()] else: # If there are local changes they win over deletion. self.diskMap[memObject.id()] = memObject self.diskChanges.resetChanges(memObject) def deletedOwnedObjects(self, memList): for obj in memList.allItemsSorted(): if isinstance(obj, NoteOwner): self._handleOwnedObjectsRemovedFromDisk(obj.notes()) if isinstance(obj, AttachmentOwner): self._handleOwnedObjectsRemovedFromDisk(obj.attachments()) if isinstance(obj, Task): self._handleEffortsRemovedFromDisk(obj.efforts()) def _handleOwnedObjectsRemovedFromDisk(self, memObjects): for memObject in memObjects: className = memObject.__class__.__name__ if className.endswith('Attachment'): className = 'Attachment' if isinstance(memObject, CompositeObject): self._handleOwnedObjectsRemovedFromDisk(memObject.children()) if isinstance(memObject, NoteOwner): self._handleOwnedObjectsRemovedFromDisk(memObject.notes()) if isinstance(memObject, AttachmentOwner): self._handleOwnedObjectsRemovedFromDisk(memObject.attachments()) diskChanges = self.diskChanges.getChanges(memObject) if diskChanges is not None and '__del__' in diskChanges: # Same remark as above if isinstance(memObject, CompositeObject): if memObject.parent() is None: getattr(self.memOwnerMap[memObject.id()], 'remove%s' % className)(memObject) else: self.memMap[memObject.parent().id()].removeChild(memObject) else: getattr(self.memOwnerMap[memObject.id()], 'remove%s' % className)(memObject) del self.memMap[memObject.id()] def _handleEffortsRemovedFromDisk(self, memEfforts): for memEffort in memEfforts: diskChanges = self.diskChanges.getChanges(memEffort) if diskChanges is not None and '__del__' in diskChanges: # Same remark as above self.memMap[memEffort.parent().id()].removeEffort(memEffort) del self.memMap[memEffort.id()] def applyChanges(self, memList): # Final: apply disk changes for memObject in self.allObjects(memList.rootItems()): diskChanges = self.diskChanges.getChanges(memObject) if diskChanges: memChanges = self._monitor.getChanges(memObject) diskObject = self.diskMap[memObject.id()] conflicts = [] for changeName in diskChanges: if changeName == '__parent__': pass # Already handled elif changeName.startswith('__add_category:'): categoryId = changeName[15:] if categoryId not in self.memMap: # Mmmh, deleted... conflicts.append(changeName) self.conflictChanges.addChange(memObject, '__del' + changeName[5:]) else: if memChanges is not None and \ '__del' + changeName[5:] in memChanges: conflicts.append(changeName) self.conflictChanges.addChange(memObject, '__del' + changeName[5:]) else: # Aaaaah finally theCategory = self.memMap[categoryId] memObject.addCategory(theCategory) theCategory.addCategorizable(memObject) elif changeName.startswith('__del_category:'): categoryId = changeName[15:] if categoryId in self.memMap: if memChanges is not None and \ '__add' + changeName[5:] in memChanges: conflicts.append(changeName) self.conflictChanges.addChange(memObject, '__add' + changeName[5:]) else: theCategory = self.memMap[categoryId] memObject.removeCategory(theCategory) theCategory.removeCategorizable(memObject) elif changeName == '__prerequisites__': diskPrereqs = set([self.memMap[obj.id()] for obj in diskObject.prerequisites()]) memPrereqs = set(memObject.prerequisites()) if memChanges is not None and \ '__prerequisites__' in memChanges and \ memPrereqs != diskPrereqs: conflicts.append('__prerequisites__') self.conflictChanges.addChange(memObject, '__prerequisites__') else: memObject.setPrerequisites(diskPrereqs) elif changeName == '__task__': # Effort changed task if memChanges is not None and \ '__task__' in memChanges and \ memObject.parent().id() != diskObject.parent().id(): conflicts.append('__task__') self.conflictChanges.addChange(memObject, '__task__') else: memObject.setTask(self.memMap[diskObject.parent().id()]) elif changeName == '__owner__': # This happens after a conflict if memChanges is not None and \ '__owner__' in memChanges and \ self.memOwnerMap[memObject.id()].id() != self.diskOwnerMap[diskObject.id()].id(): # Yet another conflict... Memory wins conflicts.append('__owner__') self.conflictChanges.addChange(memObject, '__owner__') else: className = memObject.__class__.__name__ if className.endsWith('Attachment'): className = 'Attachment' oldOwner = self.memOwnerMap[memObject.id()] newOwner = self.memOwnerMap[diskObject.id()] getattr(oldOwner, 'remove%s' % className)(memObject) getattr(newOwner, 'add%s' % className)(memObject) elif changeName == 'appearance': attrNames = ['foregroundColor', 'backgroundColor', 'font', 'icon', 'selectedIcon'] if memChanges is not None and \ 'appearance' in memChanges: for attrName in attrNames: if getattr(memObject, attrName)() != getattr(diskObject, attrName)(): conflicts.append(attrName) self.conflictChanges.addChange(memObject, 'appearance') else: for attrName in attrNames: getattr(memObject, 'set' + attrName[0].upper() + attrName[1:])(getattr(diskObject, attrName)()) elif changeName == 'expandedContexts': # Note: no conflict resolution for this one. memObject.expand(diskObject.isExpanded()) else: if changeName in ['start', 'stop']: getterName = 'get' + changeName[0].upper() + changeName[1:] else: getterName = changeName if memChanges is not None and \ changeName in memChanges and \ getattr(memObject, getterName)() != getattr(diskObject, getterName)(): conflicts.append(changeName) self.conflictChanges.addChange(memObject, changeName) else: getattr(memObject, 'set' + changeName[0].upper() + changeName[1:])(getattr(diskObject, getterName)()) if conflicts: self.notify(_('Conflicts detected for "%s".\nThe local version was used.') % memObject.subject())
def __init__(self, *args, **kwargs): self.__filename = self.__lastFilename = '' self.__needSave = self.__loading = False self.__tasks = task.TaskList() self.__categories = category.CategoryList() self.__notes = note.NoteContainer() self.__efforts = effort.EffortList(self.tasks()) self.__guid = generate() self.__syncMLConfig = createDefaultSyncConfig(self.__guid) self.__monitor = ChangeMonitor() self.__changes = dict() self.__changes[self.__monitor.guid()] = self.__monitor self.__changedOnDisk = False if kwargs.pop('poll', True): self.__notifier = TaskCoachFilesystemPollerNotifier(self) else: self.__notifier = TaskCoachFilesystemNotifier(self) self.__saving = False for collection in [self.__tasks, self.__categories, self.__notes]: self.__monitor.monitorCollection(collection) for domainClass in [ task.Task, category.Category, note.Note, effort.Effort, attachment.FileAttachment, attachment.URIAttachment, attachment.MailAttachment ]: self.__monitor.monitorClass(domainClass) super(TaskFile, self).__init__(*args, **kwargs) # Register for tasks, categories, efforts and notes being changed so we # can monitor when the task file needs saving (i.e. is 'dirty'): for container in self.tasks(), self.categories(), self.notes(): for eventType in container.modificationEventTypes(): self.registerObserver(self.onDomainObjectAddedOrRemoved, eventType, eventSource=container) for eventType in (base.Object.markDeletedEventType(), base.Object.markNotDeletedEventType()): self.registerObserver(self.onDomainObjectAddedOrRemoved, eventType) for eventType in task.Task.modificationEventTypes(): if not eventType.startswith('pubsub'): self.registerObserver(self.onTaskChanged_Deprecated, eventType) pub.subscribe(self.onTaskChanged, 'pubsub.task') for eventType in effort.Effort.modificationEventTypes(): self.registerObserver(self.onEffortChanged, eventType) for eventType in note.Note.modificationEventTypes(): if not eventType.startswith('pubsub'): self.registerObserver(self.onNoteChanged_Deprecated, eventType) pub.subscribe(self.onNoteChanged, 'pubsub.note') for eventType in category.Category.modificationEventTypes(): if not eventType.startswith('pubsub'): self.registerObserver(self.onCategoryChanged_Deprecated, eventType) pub.subscribe(self.onCategoryChanged, 'pubsub.category') for eventType in attachment.FileAttachment.modificationEventTypes() + \ attachment.URIAttachment.modificationEventTypes() + \ attachment.MailAttachment.modificationEventTypes(): if not eventType.startswith('pubsub'): self.registerObserver(self.onAttachmentChanged_Deprecated, eventType) pub.subscribe(self.onAttachmentChanged, 'pubsub.attachment')
class TaskFile(patterns.Observer): def __init__(self, *args, **kwargs): self.__filename = self.__lastFilename = '' self.__needSave = self.__loading = False self.__tasks = task.TaskList() self.__categories = category.CategoryList() self.__notes = note.NoteContainer() self.__efforts = effort.EffortList(self.tasks()) self.__guid = generate() self.__syncMLConfig = createDefaultSyncConfig(self.__guid) self.__monitor = ChangeMonitor() self.__changes = dict() self.__changes[self.__monitor.guid()] = self.__monitor self.__changedOnDisk = False if kwargs.pop('poll', True): self.__notifier = TaskCoachFilesystemPollerNotifier(self) else: self.__notifier = TaskCoachFilesystemNotifier(self) self.__saving = False for collection in [self.__tasks, self.__categories, self.__notes]: self.__monitor.monitorCollection(collection) for domainClass in [task.Task, category.Category, note.Note, effort.Effort, attachment.FileAttachment, attachment.URIAttachment, attachment.MailAttachment]: self.__monitor.monitorClass(domainClass) super(TaskFile, self).__init__(*args, **kwargs) # Register for tasks, categories, efforts and notes being changed so we # can monitor when the task file needs saving (i.e. is 'dirty'): for container in self.tasks(), self.categories(), self.notes(): for eventType in container.modificationEventTypes(): self.registerObserver(self.onDomainObjectAddedOrRemoved, eventType, eventSource=container) for eventType in (base.Object.markDeletedEventType(), base.Object.markNotDeletedEventType()): self.registerObserver(self.onDomainObjectAddedOrRemoved, eventType) for eventType in task.Task.modificationEventTypes(): if not eventType.startswith('pubsub'): self.registerObserver(self.onTaskChanged_Deprecated, eventType) pub.subscribe(self.onTaskChanged, 'pubsub.task') for eventType in effort.Effort.modificationEventTypes(): self.registerObserver(self.onEffortChanged, eventType) for eventType in note.Note.modificationEventTypes(): if not eventType.startswith('pubsub'): self.registerObserver(self.onNoteChanged_Deprecated, eventType) pub.subscribe(self.onNoteChanged, 'pubsub.note') for eventType in category.Category.modificationEventTypes(): if not eventType.startswith('pubsub'): self.registerObserver(self.onCategoryChanged_Deprecated, eventType) pub.subscribe(self.onCategoryChanged, 'pubsub.category') for eventType in attachment.FileAttachment.modificationEventTypes() + \ attachment.URIAttachment.modificationEventTypes() + \ attachment.MailAttachment.modificationEventTypes(): if not eventType.startswith('pubsub'): self.registerObserver(self.onAttachmentChanged_Deprecated, eventType) pub.subscribe(self.onAttachmentChanged, 'pubsub.attachment') def __str__(self): return self.filename() def __contains__(self, item): return item in self.tasks() or item in self.notes() or \ item in self.categories() or item in self.efforts() def monitor(self): return self.__monitor def categories(self): return self.__categories def notes(self): return self.__notes def tasks(self): return self.__tasks def efforts(self): return self.__efforts def syncMLConfig(self): return self.__syncMLConfig def guid(self): return self.__guid def changes(self): return self.__changes def setSyncMLConfig(self, config): self.__syncMLConfig = config self.markDirty() def isEmpty(self): return 0 == len(self.categories()) == len(self.tasks()) == len(self.notes()) def onDomainObjectAddedOrRemoved(self, event): # pylint: disable=W0613 if self.__loading or self.__saving: return self.markDirty() def onTaskChanged(self, newValue, sender): if self.__loading or self.__saving: return if sender in self.tasks(): self.markDirty() def onTaskChanged_Deprecated(self, event): if self.__loading: return changedTasks = [changedTask for changedTask in event.sources() \ if changedTask in self.tasks()] if changedTasks: self.markDirty() for changedTask in changedTasks: changedTask.markDirty() def onEffortChanged(self, event): if self.__loading or self.__saving: return changedEfforts = [changedEffort for changedEffort in event.sources() if \ changedEffort.task() in self.tasks()] if changedEfforts: self.markDirty() for changedEffort in changedEfforts: changedEffort.markDirty() def onCategoryChanged_Deprecated(self, event): if self.__loading or self.__saving: return changedCategories = [changedCategory for changedCategory in event.sources() if \ changedCategory in self.categories()] if changedCategories: self.markDirty() # Mark all categorizables belonging to the changed category dirty; # this is needed because in SyncML/vcard world, categories are not # first-class objects. Instead, each task/contact/etc has a # categories property which is a comma-separated list of category # names. So, when a category name changes, every associated # categorizable changes. for changedCategory in changedCategories: for categorizable in changedCategory.categorizables(): categorizable.markDirty() def onCategoryChanged(self, newValue, sender): if self.__loading or self.__saving: return changedCategories = [changedCategory for changedCategory in [sender] if \ changedCategory in self.categories()] if changedCategories: self.markDirty() # Mark all categorizables belonging to the changed category dirty; # this is needed because in SyncML/vcard world, categories are not # first-class objects. Instead, each task/contact/etc has a # categories property which is a comma-separated list of category # names. So, when a category name changes, every associated # categorizable changes. for changedCategory in changedCategories: for categorizable in changedCategory.categorizables(): categorizable.markDirty() def onNoteChanged_Deprecated(self, event): if self.__loading: return # A note may be in self.notes() or it may be a note of another # domain object. self.markDirty() for changedNote in event.sources(): changedNote.markDirty() def onNoteChanged(self, newValue, sender): if self.__loading: return # A note may be in self.notes() or it may be a note of another # domain object. self.markDirty() sender.markDirty() def onAttachmentChanged(self, newValue, sender): if self.__loading or self.__saving: return # Attachments don't know their owner, so we can't check whether the # attachment is actually in the task file. Assume it is. self.markDirty() def onAttachmentChanged_Deprecated(self, event): if self.__loading: return # Attachments don't know their owner, so we can't check whether the # attachment is actually in the task file. Assume it is. self.markDirty() for changedAttachment in event.sources(): changedAttachment.markDirty() def setFilename(self, filename): if filename == self.__filename: return self.__lastFilename = filename or self.__filename self.__filename = filename self.__notifier.setFilename(filename) pub.sendMessage('taskfile.filenameChanged', filename=filename) def setLastFilename(self, filename): self.__lastFilename = filename def filename(self): return self.__filename def lastFilename(self): return self.__lastFilename def isDirty(self): return self.__needSave def markDirty(self, force=False): if force or not self.__needSave: self.__needSave = True pub.sendMessage('taskfile.dirty', taskFile=self) def markClean(self): if self.__needSave: self.__needSave = False pub.sendMessage('taskfile.clean', taskFile=self) def onFileChanged(self): if not self.__saving: import wx # Not really clean but we're in another thread... self.__changedOnDisk = True wx.CallAfter(pub.sendMessage, 'taskfile.changed', taskFile=self) @patterns.eventSource def clear(self, regenerate=True, event=None): pub.sendMessage('taskfile.aboutToClear', taskFile=self) try: self.tasks().clear(event=event) self.categories().clear(event=event) self.notes().clear(event=event) if regenerate: self.__guid = generate() self.__syncMLConfig = createDefaultSyncConfig(self.__guid) finally: pub.sendMessage('taskfile.justCleared', taskFile=self) def close(self): if os.path.exists(self.filename()): changes = xml.ChangesXMLReader(self.filename() + '.delta').read() del changes[self.__monitor.guid()] xml.ChangesXMLWriter(file(self.filename() + '.delta', 'wb')).write(changes) self.setFilename('') self.__guid = generate() self.clear() self.__monitor.reset() self.markClean() self.__changedOnDisk = False def stop(self): self.__notifier.stop() def _read(self, fd): return xml.XMLReader(fd).read() def exists(self): return os.path.isfile(self.__filename) def _openForRead(self): return file(self.__filename, 'rU') def _openForWrite(self): name = getTemporaryFileName(os.path.split(self.__filename)[0]) return name, file(name, 'w') def load(self, filename=None): pub.sendMessage('taskfile.aboutToRead', taskFile=self) self.__loading = True if filename: self.setFilename(filename) try: if self.exists(): fd = self._openForRead() tasks, categories, globalcategories, notes, syncMLConfig, changes, guid = self._read(fd) fd.close() else: tasks = [] categories = [] notes = [] changes = dict() guid = generate() syncMLConfig = createDefaultSyncConfig(guid) self.clear() self.__monitor.reset() self.__changes = changes self.__changes[self.__monitor.guid()] = self.__monitor self.categories().extend(categories) self.tasks().extend(tasks) self.notes().extend(notes) def registerOtherObjects(objects): for obj in objects: if isinstance(obj, base.CompositeObject): registerOtherObjects(obj.children()) if isinstance(obj, note.NoteOwner): registerOtherObjects(obj.notes()) if isinstance(obj, attachment.AttachmentOwner): registerOtherObjects(obj.attachments()) if isinstance(obj, task.Task): registerOtherObjects(obj.efforts()) if isinstance(obj, note.Note) or \ isinstance(obj, attachment.Attachment) or \ isinstance(obj, effort.Effort): self.__monitor.setChanges(obj.id(), set()) registerOtherObjects(self.categories().rootItems()) registerOtherObjects(self.tasks().rootItems()) registerOtherObjects(self.notes().rootItems()) self.__monitor.resetAllChanges() self.__syncMLConfig = syncMLConfig self.__guid = guid if os.path.exists(self.filename()): # We need to reset the changes on disk because we're up to date. xml.ChangesXMLWriter(file(self.filename() + '.delta', 'wb')).write(self.__changes) except: self.setFilename('') raise finally: self.__loading = False self.markClean() self.__changedOnDisk = False pub.sendMessage('taskfile.justRead', taskFile=self) def save(self): pub.sendMessage('taskfile.aboutToSave', taskFile=self) # When encountering a problem while saving (disk full, # computer on fire), if we were writing directly to the file, # it's lost. So write to a temporary file and rename it if # everything went OK. self.__saving = True try: self.mergeDiskChanges() if self.__needSave or not os.path.exists(self.__filename): name, fd = self._openForWrite() xml.XMLWriter(fd).write(self.tasks(), self.categories(), self.notes(), self.syncMLConfig(), self.guid()) fd.close() if os.path.exists(self.__filename): # Not using self.exists() because DummyFile.exists returns True os.remove(self.__filename) if name is not None: # Unit tests (AutoSaver) os.rename(name, self.__filename) self.markClean() finally: self.__saving = False self.__notifier.saved() def mergeDiskChanges(self): self.__loading = True try: if os.path.exists(self.__filename): # Not using self.exists() because DummyFile.exists returns True # Instead of writing the content of memory, merge changes # with the on-disk version and save the result. self.__monitor.freeze() try: fd = self._openForRead() tasks, categories, globalcategories, notes, syncMLConfig, allChanges, guid = self._read(fd) fd.close() self.__changes = allChanges if self.__saving: for devGUID, changes in self.__changes.items(): if devGUID != self.__monitor.guid(): changes.merge(self.__monitor) sync = ChangeSynchronizer(self.__monitor, allChanges) sync.sync( [(self.categories(), category.CategoryList(categories)), (self.tasks(), task.TaskList(tasks)), (self.notes(), note.NoteContainer(notes))] ) self.__changes[self.__monitor.guid()] = self.__monitor finally: self.__monitor.thaw() else: self.__changes = {self.__monitor.guid(): self.__monitor} self.__monitor.resetAllChanges() name, fd = self._openForWrite() xml.ChangesXMLWriter(fd).write(self.changes()) fd.close() if os.path.exists(self.__filename + '.delta'): os.remove(self.__filename + '.delta') if name is not None: # Unit tests (AutoSaver) os.rename(name, self.__filename + '.delta') self.__changedOnDisk = False finally: self.__loading = False def saveas(self, filename): self.setFilename(filename) self.save() def merge(self, filename): mergeFile = self.__class__() mergeFile.load(filename) self.__loading = True categoryMap = dict() self.tasks().removeItems(self.objectsToOverwrite(self.tasks(), mergeFile.tasks())) self.rememberCategoryLinks(categoryMap, self.tasks()) self.tasks().extend(mergeFile.tasks().rootItems()) self.notes().removeItems(self.objectsToOverwrite(self.notes(), mergeFile.notes())) self.rememberCategoryLinks(categoryMap, self.notes()) self.notes().extend(mergeFile.notes().rootItems()) self.categories().removeItems(self.objectsToOverwrite(self.categories(), mergeFile.categories())) self.categories().extend(mergeFile.categories().rootItems()) self.restoreCategoryLinks(categoryMap) mergeFile.close() self.__loading = False self.markDirty(force=True) def objectsToOverwrite(self, originalObjects, objectsToMerge): objectsToOverwrite = [] for domainObject in objectsToMerge: try: objectsToOverwrite.append(originalObjects.getObjectById(domainObject.id())) except IndexError: pass return objectsToOverwrite def rememberCategoryLinks(self, categoryMap, categorizables): for categorizable in categorizables: for categoryToLinkLater in categorizable.categories(): categoryMap.setdefault(categoryToLinkLater.id(), []).append(categorizable) def restoreCategoryLinks(self, categoryMap): categories = self.categories() for categoryId, categorizables in categoryMap.iteritems(): try: categoryToLink = categories.getObjectById(categoryId) except IndexError: continue # Subcategory was removed by the merge for categorizable in categorizables: categorizable.addCategory(categoryToLink) categoryToLink.addCategorizable(categorizable) def needSave(self): return not self.__loading and self.__needSave def changedOnDisk(self): return self.__changedOnDisk def beginSync(self): self.__loading = True def endSync(self): self.__loading = False self.markDirty()
class ChangeSynchronizer(object): def __init__(self, monitor, allChanges): self._monitor = monitor self._allChanges = allChanges @staticmethod def allObjects(theList): result = list() for obj in theList: result.append(obj) if isinstance(obj, CompositeObject): result.extend(ChangeSynchronizer.allObjects(obj.children())) if isinstance(obj, NoteOwner): result.extend(ChangeSynchronizer.allObjects(obj.notes())) if isinstance(obj, AttachmentOwner): result.extend(ChangeSynchronizer.allObjects(obj.attachments())) if isinstance(obj, Task): result.extend(obj.efforts()) return result def sync(self, lists): self.diskChanges = ChangeMonitor() self.conflictChanges = ChangeMonitor() self.notifier = AbstractNotifier.getSimple() self.memMap = dict() self.memOwnerMap = dict() self.diskMap = dict() self.diskOwnerMap = dict() for devGUID, changes in self._allChanges.items(): if devGUID == self._monitor.guid(): self.diskChanges = changes break self._allChanges[self._monitor.guid()] = self._monitor for memList, diskList in lists: self.mergeObjects(memList, diskList) # Cleanup monitor self._monitor.empty() for memList, diskList in lists: for obj in self.allObjects(memList.rootItems()): self._monitor.resetChanges(obj) # Merge conflict changes for devGUID, changes in self._allChanges.items(): if devGUID != self._monitor.guid(): changes.merge(self.conflictChanges) def notify(self, message): self.notifier.notify(_('Task Coach'), message, wx.ArtProvider.GetBitmap('taskcoach', size=wx.Size(32, 32))) def mergeObjects(self, memList, diskList): # Map id to object def addIds(objects, idMap, ownerMap, owner=None): for obj in objects: idMap[obj.id()] = obj if owner is not None: ownerMap[obj.id()] = owner if isinstance(obj, CompositeObject): addIds(obj.children(), idMap, ownerMap) if isinstance(obj, NoteOwner): addIds(obj.notes(), idMap, ownerMap, obj) if isinstance(obj, AttachmentOwner): addIds(obj.attachments(), idMap, ownerMap, obj) if isinstance(obj, Task): addIds(obj.efforts(), idMap, ownerMap) addIds(memList, self.memMap, self.memOwnerMap) addIds(diskList, self.diskMap, self.diskOwnerMap) self.mergeCompositeObjects(memList, diskList) self.mergeOwnedObjectsFromDisk(diskList) self.reparentObjects(memList, diskList) self.deletedObjects(memList) self.deletedOwnedObjects(memList) self.applyChanges(memList) def mergeCompositeObjects(self, memList, diskList): # First pass: new composite objects on disk. Don't handle # other (notes belonging to object, attachments, efforts) yet # because they may belong to a category. This assumes that # categories are the first domain class handled. for diskObject in diskList.allItemsSorted(): memChanges = self._monitor.getChanges(diskObject) deleted = memChanges is not None and '__del__' in memChanges diskChanges = self.diskChanges.getChanges(diskObject) if deleted and diskChanges is not None and '__del__' not in diskChanges and len(diskChanges) > 0: # "undelete" it memChanges.remove('__del__') deleted = False if diskObject.id() not in self.memMap and not deleted: if isinstance(diskObject, CompositeObject): # New children will be handled later. This assumes # that the parent() is not changed when removing a # child. for child in diskObject.children(): diskObject.removeChild(child) parent = diskObject.parent() if parent is not None and parent.id() in self.memMap: parent = self.memMap[parent.id()] parent.addChild(diskObject) diskObject.setParent(parent) elif parent is not None: # Parent deleted from memory; the task will be # top-level. diskObject.setParent(None) self.conflictChanges.addChange(diskObject, '__parent__') self.notify(_('"%s" became top-level because its parent was locally deleted.') % diskObject.subject()) memList.append(diskObject) self.memMap[diskObject.id()] = diskObject def mergeOwnedObjectsFromDisk(self, diskList): # Second pass: '******' objects (notes and attachments # currently) new on disk, and efforts. for obj in diskList.allItemsSorted(): if isinstance(obj, NoteOwner): self._handleNewOwnedObjectsOnDisk(obj.notes()) if isinstance(obj, AttachmentOwner): self._handleNewOwnedObjectsOnDisk(obj.attachments()) if isinstance(obj, Task): self._handleNewEffortsOnDisk(obj.efforts()) def _handleNewOwnedObjectsOnDisk(self, diskObjects): for diskObject in diskObjects: className = diskObject.__class__.__name__ if className.endswith('Attachment'): className = 'Attachment' if isinstance(diskObject, CompositeObject): children = diskObject.children()[:] memChanges = self._monitor.getChanges(diskObject) deleted = memChanges is not None and '__del__' in memChanges if diskObject.id() not in self.memMap and not deleted: addObject = True if isinstance(diskObject, CompositeObject): for child in diskObject.children(): diskObject.removeChild(child) parent = diskObject.parent() if parent is not None and parent.id() in self.memMap: parent = self.memMap[parent.id()] parent.addChild(diskObject) diskObject.setParent(parent) elif parent is not None: # Parent deleted from memory; the object # becomes top-level but its owner stays # the same. diskObject.setParent(None) while parent.parent() is not None: parent = parent.parent() diskOwner = self.diskOwnerMap[parent.id()] if diskOwner.id() in self.memMap: memOwner = self.memMap[diskOwner.id()] getattr(memOwner, 'add%s' % className)(diskObject) self.conflictChanges.addChange(diskObject, '__owner__') self.memOwnerMap[diskObject.id()] = memOwner self.notify(_('"%s" became top-level because its parent was locally deleted.') % diskObject.subject()) else: diskOwner = self.diskOwnerMap[diskObject.id()] if diskOwner.id() in self.memMap: memOwner = self.memMap[diskOwner.id()] getattr(memOwner, 'add%s' % className)(diskObject) self.memOwnerMap[diskObject.id()] = memOwner else: # Owner deleted. Just forget it. self.conflictChanges.addChange(diskObject, '__del__') addObject = False self.notify(_('"%s" was not kept because its owner ("%s") was locally deleted.') % (diskObject.subject(), diskOwner.subject())) else: diskOwner = self.diskOwnerMap[diskObject.id()] if diskOwner.id() in self.memMap: memOwner = self.memMap[diskOwner.id()] getattr(memOwner, 'add%s' % className)(diskObject) self.memOwnerMap[diskObject.id()] = memOwner else: # Forget it again... self.conflictChanges.addChange(diskObject, '__del__') addObject = False self.notify(_('"%s" was not kept because its owner ("%s") was locally deleted.') % (diskObject.subject(), diskOwner.subject())) if addObject: self.memMap[diskObject.id()] = diskObject if diskObject.id() in self.memMap: if isinstance(diskObject, CompositeObject): self._handleNewOwnedObjectsOnDisk(children) if isinstance(diskObject, NoteOwner): self._handleNewOwnedObjectsOnDisk(diskObject.notes()) if isinstance(diskObject, AttachmentOwner): self._handleNewOwnedObjectsOnDisk(diskObject.attachments()) def _handleNewEffortsOnDisk(self, diskEfforts): for diskEffort in diskEfforts: memChanges = self._monitor.getChanges(diskEffort) deleted = memChanges is not None and '__del__' in memChanges if diskEffort.id() not in self.memMap and not deleted: diskTask = diskEffort.parent() if diskTask.id() in self.memMap: memTask = self.memMap[diskTask.id()] diskEffort.setTask(memTask) self.memMap[diskEffort.id()] = diskEffort else: # Task deleted; forget it. self.conflictChanges.addChange(diskEffort, '__del__') self.notify(_('Effort discarded because its owner ("%s") was locally deleted.') % diskTask.subject()) def reparentObjects(self, memList, diskList): # Third pass: objects reparented on disk. for diskObject in self.allObjects(diskList): diskChanges = self.diskChanges.getChanges(diskObject) if diskChanges is not None and '__parent__' in diskChanges: memChanges = self._monitor.getChanges(diskObject) memObject = self.memMap[diskObject.id()] memList.remove(memObject) # Note: no conflict resolution for this one, # it would be a bit tricky... Instead, the # disk version wins. def sameParents(a, b): if a is None and b is None: return True elif a is None or b is None: return False return a.id() == b.id() parentConflict = False if memChanges is not None and '__parent__' in memChanges: if not sameParents(memObject.parent(), diskObject.parent()): parentConflict = True if memObject.parent() is not None: memObject.parent().removeChild(memObject) if parentConflict: diskParent = diskObject.parent() if diskParent is None: memObject.setParent(None) else: if diskParent.id() in self.memMap: memParent = self.memMap[diskParent.id()] memParent.addChild(memObject) memObject.setParent(memParent) else: # New parent deleted from memory... memObject.setParent(None) self.conflictChanges.addChange(memObject, '__parent__') else: diskParent = diskObject.parent() if diskParent is None: memObject.setParent(None) else: if diskParent.id() in self.memMap: memParent = self.memMap[diskParent.id()] memParent.addChild(memObject) memObject.setParent(memParent) else: memObject.setParent(None) self.conflictChanges.addChange(memObject, '__parent__') memList.append(memObject) def deletedObjects(self, memList): # Fourth pass: objects deleted from disk for memObject in memList.allItemsSorted(): diskChanges = self.diskChanges.getChanges(memObject) memChanges = self._monitor.getChanges(memObject) if diskChanges is not None and '__del__' in diskChanges: if (memChanges is None or '__del__' in memChanges or len(memChanges) == 0): memList.remove(memObject) del self.memMap[memObject.id()] if memObject.id() in self.memOwnerMap: del self.memOwnerMap[memObject.id()] else: # If there are local changes they win over deletion. self.diskMap[memObject.id()] = memObject self.diskChanges.resetChanges(memObject) def deletedOwnedObjects(self, memList): for obj in memList.allItemsSorted(): if isinstance(obj, NoteOwner): self._handleOwnedObjectsRemovedFromDisk(obj.notes()) if isinstance(obj, AttachmentOwner): self._handleOwnedObjectsRemovedFromDisk(obj.attachments()) if isinstance(obj, Task): self._handleEffortsRemovedFromDisk(obj.efforts()) def _handleOwnedObjectsRemovedFromDisk(self, memObjects): for memObject in memObjects: className = memObject.__class__.__name__ if className.endswith('Attachment'): className = 'Attachment' if isinstance(memObject, CompositeObject): self._handleOwnedObjectsRemovedFromDisk(memObject.children()) if isinstance(memObject, NoteOwner): self._handleOwnedObjectsRemovedFromDisk(memObject.notes()) if isinstance(memObject, AttachmentOwner): self._handleOwnedObjectsRemovedFromDisk(memObject.attachments()) diskChanges = self.diskChanges.getChanges(memObject) if diskChanges is not None and '__del__' in diskChanges: # Same remark as above if isinstance(memObject, CompositeObject): if memObject.parent() is None: getattr(self.memOwnerMap[memObject.id()], 'remove%s' % className)(memObject) else: self.memMap[memObject.parent().id()].removeChild(memObject) else: getattr(self.memOwnerMap[memObject.id()], 'remove%s' % className)(memObject) del self.memMap[memObject.id()] def _handleEffortsRemovedFromDisk(self, memEfforts): for memEffort in memEfforts: diskChanges = self.diskChanges.getChanges(memEffort) if diskChanges is not None and '__del__' in diskChanges: # Same remark as above self.memMap[memEffort.parent().id()].removeEffort(memEffort) del self.memMap[memEffort.id()] def applyChanges(self, memList): # Final: apply disk changes for memObject in self.allObjects(memList.rootItems()): diskChanges = self.diskChanges.getChanges(memObject) if diskChanges: memChanges = self._monitor.getChanges(memObject) diskObject = self.diskMap[memObject.id()] conflicts = [] for changeName in diskChanges: if changeName == '__parent__': pass # Already handled elif changeName.startswith('__add_category:'): categoryId = changeName[15:] if categoryId not in self.memMap: # Mmmh, deleted... conflicts.append(changeName) self.conflictChanges.addChange(memObject, '__del' + changeName[5:]) else: if memChanges is not None and \ '__del' + changeName[5:] in memChanges: conflicts.append(changeName) self.conflictChanges.addChange(memObject, '__del' + changeName[5:]) else: # Aaaaah finally theCategory = self.memMap[categoryId] memObject.addCategory(theCategory) theCategory.addCategorizable(memObject) elif changeName.startswith('__del_category:'): categoryId = changeName[15:] if categoryId in self.memMap: if memChanges is not None and \ '__add' + changeName[5:] in memChanges: conflicts.append(changeName) self.conflictChanges.addChange(memObject, '__add' + changeName[5:]) else: theCategory = self.memMap[categoryId] memObject.removeCategory(theCategory) theCategory.removeCategorizable(memObject) elif changeName == '__prerequisites__': diskPrereqs = set([self.memMap[obj.id()] for obj in diskObject.prerequisites()]) memPrereqs = set(memObject.prerequisites()) if memChanges is not None and \ '__prerequisites__' in memChanges and \ memPrereqs != diskPrereqs: conflicts.append('__prerequisites__') self.conflictChanges.addChange(memObject, '__prerequisites__') else: memObject.setPrerequisites(diskPrereqs) elif changeName == '__task__': # Effort changed task if memChanges is not None and \ '__task__' in memChanges and \ memObject.parent().id() != diskObject.parent().id(): conflicts.append('__task__') self.conflictChanges.addChange(memObject, '__task__') else: memObject.setTask(self.memMap[diskObject.parent().id()]) elif changeName == '__owner__': # This happens after a conflict if memChanges is not None and \ '__owner__' in memChanges and \ self.memOwnerMap[memObject.id()].id() != self.diskOwnerMap[diskObject.id()].id(): # Yet another conflict... Memory wins conflicts.append('__owner__') self.conflictChanges.addChange(memObject, '__owner__') else: className = memObject.__class__.__name__ if className.endsWith('Attachment'): className = 'Attachment' oldOwner = self.memOwnerMap[memObject.id()] newOwner = self.memOwnerMap[diskObject.id()] getattr(oldOwner, 'remove%s' % className)(memObject) getattr(newOwner, 'add%s' % className)(memObject) elif changeName == 'appearance': attrNames = ['foregroundColor', 'backgroundColor', 'font', 'icon', 'selectedIcon'] if memChanges is not None and \ 'appearance' in memChanges: for attrName in attrNames: if getattr(memObject, attrName)() != getattr(diskObject, attrName)(): conflicts.append(attrName) self.conflictChanges.addChange(memObject, 'appearance') else: for attrName in attrNames: getattr(memObject, 'set' + attrName[0].upper() + attrName[1:])(getattr(diskObject, attrName)()) elif changeName == 'expandedContexts': # Note: no conflict resolution for this one. memObject.expand(diskObject.isExpanded()) else: if changeName in ['start', 'stop']: getterName = 'get' + changeName[0].upper() + changeName[1:] else: getterName = changeName if memChanges is not None and \ changeName in memChanges and \ getattr(memObject, getterName)() != getattr(diskObject, getterName)(): conflicts.append(changeName) self.conflictChanges.addChange(memObject, changeName) else: getattr(memObject, 'set' + changeName[0].upper() + changeName[1:])(getattr(diskObject, getterName)()) if conflicts: self.notify(_('Conflicts detected for "%s".\nThe local version was used.') % memObject.subject())
class TaskFile(patterns.Observer): def __init__(self, *args, **kwargs): self.__filename = self.__lastFilename = '' self.__needSave = self.__loading = False self.__tasks = task.TaskList() self.__categories = category.CategoryList() self.__notes = note.NoteContainer() self.__efforts = effort.EffortList(self.tasks()) self.__guid = generate() self.__syncMLConfig = createDefaultSyncConfig(self.__guid) self.__monitor = ChangeMonitor() self.__changes = dict() self.__changes[self.__monitor.guid()] = self.__monitor self.__changedOnDisk = False if kwargs.pop('poll', True): self.__notifier = TaskCoachFilesystemPollerNotifier(self) else: self.__notifier = TaskCoachFilesystemNotifier(self) self.__saving = False for collection in [self.__tasks, self.__categories, self.__notes]: self.__monitor.monitorCollection(collection) for domainClass in [ task.Task, category.Category, note.Note, effort.Effort, attachment.FileAttachment, attachment.URIAttachment, attachment.MailAttachment ]: self.__monitor.monitorClass(domainClass) super(TaskFile, self).__init__(*args, **kwargs) # Register for tasks, categories, efforts and notes being changed so we # can monitor when the task file needs saving (i.e. is 'dirty'): for container in self.tasks(), self.categories(), self.notes(): for eventType in container.modificationEventTypes(): self.registerObserver(self.onDomainObjectAddedOrRemoved, eventType, eventSource=container) for eventType in (base.Object.markDeletedEventType(), base.Object.markNotDeletedEventType()): self.registerObserver(self.onDomainObjectAddedOrRemoved, eventType) for eventType in task.Task.modificationEventTypes(): if not eventType.startswith('pubsub'): self.registerObserver(self.onTaskChanged_Deprecated, eventType) pub.subscribe(self.onTaskChanged, 'pubsub.task') for eventType in effort.Effort.modificationEventTypes(): self.registerObserver(self.onEffortChanged, eventType) for eventType in note.Note.modificationEventTypes(): if not eventType.startswith('pubsub'): self.registerObserver(self.onNoteChanged_Deprecated, eventType) pub.subscribe(self.onNoteChanged, 'pubsub.note') for eventType in category.Category.modificationEventTypes(): if not eventType.startswith('pubsub'): self.registerObserver(self.onCategoryChanged_Deprecated, eventType) pub.subscribe(self.onCategoryChanged, 'pubsub.category') for eventType in attachment.FileAttachment.modificationEventTypes() + \ attachment.URIAttachment.modificationEventTypes() + \ attachment.MailAttachment.modificationEventTypes(): if not eventType.startswith('pubsub'): self.registerObserver(self.onAttachmentChanged_Deprecated, eventType) pub.subscribe(self.onAttachmentChanged, 'pubsub.attachment') def __str__(self): return self.filename() def __contains__(self, item): return item in self.tasks() or item in self.notes() or \ item in self.categories() or item in self.efforts() def monitor(self): return self.__monitor def categories(self): return self.__categories def notes(self): return self.__notes def tasks(self): return self.__tasks def efforts(self): return self.__efforts def syncMLConfig(self): return self.__syncMLConfig def guid(self): return self.__guid def changes(self): return self.__changes def setSyncMLConfig(self, config): self.__syncMLConfig = config self.markDirty() def isEmpty(self): return 0 == len(self.categories()) == len(self.tasks()) == len( self.notes()) def onDomainObjectAddedOrRemoved(self, event): # pylint: disable=W0613 if self.__loading or self.__saving: return self.markDirty() def onTaskChanged(self, newValue, sender): if self.__loading or self.__saving: return if sender in self.tasks(): self.markDirty() def onTaskChanged_Deprecated(self, event): if self.__loading: return changedTasks = [changedTask for changedTask in event.sources() \ if changedTask in self.tasks()] if changedTasks: self.markDirty() for changedTask in changedTasks: changedTask.markDirty() def onEffortChanged(self, event): if self.__loading or self.__saving: return changedEfforts = [changedEffort for changedEffort in event.sources() if \ changedEffort.task() in self.tasks()] if changedEfforts: self.markDirty() for changedEffort in changedEfforts: changedEffort.markDirty() def onCategoryChanged_Deprecated(self, event): if self.__loading or self.__saving: return changedCategories = [changedCategory for changedCategory in event.sources() if \ changedCategory in self.categories()] if changedCategories: self.markDirty() # Mark all categorizables belonging to the changed category dirty; # this is needed because in SyncML/vcard world, categories are not # first-class objects. Instead, each task/contact/etc has a # categories property which is a comma-separated list of category # names. So, when a category name changes, every associated # categorizable changes. for changedCategory in changedCategories: for categorizable in changedCategory.categorizables(): categorizable.markDirty() def onCategoryChanged(self, newValue, sender): if self.__loading or self.__saving: return changedCategories = [changedCategory for changedCategory in [sender] if \ changedCategory in self.categories()] if changedCategories: self.markDirty() # Mark all categorizables belonging to the changed category dirty; # this is needed because in SyncML/vcard world, categories are not # first-class objects. Instead, each task/contact/etc has a # categories property which is a comma-separated list of category # names. So, when a category name changes, every associated # categorizable changes. for changedCategory in changedCategories: for categorizable in changedCategory.categorizables(): categorizable.markDirty() def onNoteChanged_Deprecated(self, event): if self.__loading: return # A note may be in self.notes() or it may be a note of another # domain object. self.markDirty() for changedNote in event.sources(): changedNote.markDirty() def onNoteChanged(self, newValue, sender): if self.__loading: return # A note may be in self.notes() or it may be a note of another # domain object. self.markDirty() sender.markDirty() def onAttachmentChanged(self, newValue, sender): if self.__loading or self.__saving: return # Attachments don't know their owner, so we can't check whether the # attachment is actually in the task file. Assume it is. self.markDirty() def onAttachmentChanged_Deprecated(self, event): if self.__loading: return # Attachments don't know their owner, so we can't check whether the # attachment is actually in the task file. Assume it is. self.markDirty() for changedAttachment in event.sources(): changedAttachment.markDirty() def setFilename(self, filename): if filename == self.__filename: return self.__lastFilename = filename or self.__filename self.__filename = filename self.__notifier.setFilename(filename) pub.sendMessage('taskfile.filenameChanged', filename=filename) def filename(self): return self.__filename def lastFilename(self): return self.__lastFilename def isDirty(self): return self.__needSave def markDirty(self, force=False): if force or not self.__needSave: self.__needSave = True pub.sendMessage('taskfile.dirty', taskFile=self) def markClean(self): if self.__needSave: self.__needSave = False pub.sendMessage('taskfile.clean', taskFile=self) def onFileChanged(self): if not self.__saving: import wx # Not really clean but we're in another thread... self.__changedOnDisk = True wx.CallAfter(pub.sendMessage, 'taskfile.changed', taskFile=self) @patterns.eventSource def clear(self, regenerate=True, event=None): pub.sendMessage('taskfile.aboutToClear', taskFile=self) try: self.tasks().clear(event=event) self.categories().clear(event=event) self.notes().clear(event=event) if regenerate: self.__guid = generate() self.__syncMLConfig = createDefaultSyncConfig(self.__guid) finally: pub.sendMessage('taskfile.justCleared', taskFile=self) def close(self): if os.path.exists(self.filename()): changes = xml.ChangesXMLReader(self.filename() + '.delta').read() del changes[self.__monitor.guid()] xml.ChangesXMLWriter(file(self.filename() + '.delta', 'wb')).write(changes) self.setFilename('') self.__guid = generate() self.clear() self.__monitor.reset() self.markClean() self.__changedOnDisk = False def stop(self): self.__notifier.stop() def _read(self, fd): return xml.XMLReader(fd).read() def exists(self): return os.path.isfile(self.__filename) def _openForWrite(self, suffix=''): return SafeWriteFile(self.__filename + suffix) def _openForRead(self): return file(self.__filename, 'rU') def load(self, filename=None): pub.sendMessage('taskfile.aboutToRead', taskFile=self) self.__loading = True if filename: self.setFilename(filename) try: if self.exists(): fd = self._openForRead() try: tasks, categories, notes, syncMLConfig, changes, guid = self._read( fd) finally: fd.close() else: tasks = [] categories = [] notes = [] changes = dict() guid = generate() syncMLConfig = createDefaultSyncConfig(guid) self.clear() self.__monitor.reset() self.__changes = changes self.__changes[self.__monitor.guid()] = self.__monitor self.categories().extend(categories) self.tasks().extend(tasks) self.notes().extend(notes) def registerOtherObjects(objects): for obj in objects: if isinstance(obj, base.CompositeObject): registerOtherObjects(obj.children()) if isinstance(obj, note.NoteOwner): registerOtherObjects(obj.notes()) if isinstance(obj, attachment.AttachmentOwner): registerOtherObjects(obj.attachments()) if isinstance(obj, task.Task): registerOtherObjects(obj.efforts()) if isinstance(obj, note.Note) or \ isinstance(obj, attachment.Attachment) or \ isinstance(obj, effort.Effort): self.__monitor.setChanges(obj.id(), set()) registerOtherObjects(self.categories().rootItems()) registerOtherObjects(self.tasks().rootItems()) registerOtherObjects(self.notes().rootItems()) self.__monitor.resetAllChanges() self.__syncMLConfig = syncMLConfig self.__guid = guid if os.path.exists(self.filename()): # We need to reset the changes on disk because we're up to date. xml.ChangesXMLWriter(file(self.filename() + '.delta', 'wb')).write(self.__changes) except: self.setFilename('') raise finally: self.__loading = False self.markClean() self.__changedOnDisk = False pub.sendMessage('taskfile.justRead', taskFile=self) def save(self): try: pub.sendMessage('taskfile.aboutToSave', taskFile=self) except: pass # When encountering a problem while saving (disk full, # computer on fire), if we were writing directly to the file, # it's lost. So write to a temporary file and rename it if # everything went OK. self.__saving = True try: self.mergeDiskChanges() if self.__needSave or not os.path.exists(self.__filename): fd = self._openForWrite() try: xml.XMLWriter(fd).write(self.tasks(), self.categories(), self.notes(), self.syncMLConfig(), self.guid()) finally: fd.close() self.markClean() finally: self.__saving = False self.__notifier.saved() try: pub.sendMessage('taskfile.justSaved', taskFile=self) except: pass def mergeDiskChanges(self): self.__loading = True try: if os.path.exists( self.__filename ): # Not using self.exists() because DummyFile.exists returns True # Instead of writing the content of memory, merge changes # with the on-disk version and save the result. self.__monitor.freeze() try: fd = self._openForRead() tasks, categories, notes, syncMLConfig, allChanges, guid = self._read( fd) fd.close() self.__changes = allChanges if self.__saving: for devGUID, changes in self.__changes.items(): if devGUID != self.__monitor.guid(): changes.merge(self.__monitor) sync = ChangeSynchronizer(self.__monitor, allChanges) sync.sync([(self.categories(), category.CategoryList(categories)), (self.tasks(), task.TaskList(tasks)), (self.notes(), note.NoteContainer(notes))]) self.__changes[self.__monitor.guid()] = self.__monitor finally: self.__monitor.thaw() else: self.__changes = {self.__monitor.guid(): self.__monitor} self.__monitor.resetAllChanges() fd = self._openForWrite('.delta') try: xml.ChangesXMLWriter(fd).write(self.changes()) finally: fd.close() self.__changedOnDisk = False finally: self.__loading = False def saveas(self, filename): if os.path.exists(filename): os.remove(filename) if os.path.exists(filename + '.delta'): os.remove(filename + '.delta') self.setFilename(filename) self.save() def merge(self, filename): mergeFile = self.__class__() mergeFile.load(filename) self.__loading = True categoryMap = dict() self.tasks().removeItems( self.objectsToOverwrite(self.tasks(), mergeFile.tasks())) self.rememberCategoryLinks(categoryMap, self.tasks()) self.tasks().extend(mergeFile.tasks().rootItems()) self.notes().removeItems( self.objectsToOverwrite(self.notes(), mergeFile.notes())) self.rememberCategoryLinks(categoryMap, self.notes()) self.notes().extend(mergeFile.notes().rootItems()) self.categories().removeItems( self.objectsToOverwrite(self.categories(), mergeFile.categories())) self.categories().extend(mergeFile.categories().rootItems()) self.restoreCategoryLinks(categoryMap) mergeFile.close() self.__loading = False self.markDirty(force=True) def objectsToOverwrite(self, originalObjects, objectsToMerge): objectsToOverwrite = [] for domainObject in objectsToMerge: try: objectsToOverwrite.append( originalObjects.getObjectById(domainObject.id())) except IndexError: pass return objectsToOverwrite def rememberCategoryLinks(self, categoryMap, categorizables): for categorizable in categorizables: for categoryToLinkLater in categorizable.categories(): categoryMap.setdefault(categoryToLinkLater.id(), []).append(categorizable) def restoreCategoryLinks(self, categoryMap): categories = self.categories() for categoryId, categorizables in categoryMap.iteritems(): try: categoryToLink = categories.getObjectById(categoryId) except IndexError: continue # Subcategory was removed by the merge for categorizable in categorizables: categorizable.addCategory(categoryToLink) categoryToLink.addCategorizable(categorizable) def needSave(self): return not self.__loading and self.__needSave def changedOnDisk(self): return self.__changedOnDisk def beginSync(self): self.__loading = True def endSync(self): self.__loading = False self.markDirty()