From 4030cf86fd3cdb69cda92dcaccdf1d9d5e3fee99 Mon Sep 17 00:00:00 2001 From: Francis Lachapelle Date: Fri, 19 Feb 2016 23:19:07 -0500 Subject: [PATCH] (feature) Live loading of (GCS) addressbooks Using md-on-demand of md-virtual-repeat, we now progressively load the cards metadata of a personal addressbook. --- SoObjects/Contacts/SOGoContactGCSFolder.h | 12 +- SoObjects/Contacts/SOGoContactGCSFolder.m | 60 ++- UI/Contacts/UIxContactsListActions.h | 5 + UI/Contacts/UIxContactsListActions.m | 253 ++++++++++++- UI/Contacts/product.plist | 5 + .../ContactsUI/UIxContactFoldersView.wox | 13 +- .../js/Contacts/AddressBook.service.js | 346 ++++++++++++++---- .../js/Contacts/Card.service.js | 18 +- .../js/Contacts/CardController.js | 9 +- 9 files changed, 606 insertions(+), 115 deletions(-) diff --git a/SoObjects/Contacts/SOGoContactGCSFolder.h b/SoObjects/Contacts/SOGoContactGCSFolder.h index 496a1796c..4447aa186 100644 --- a/SoObjects/Contacts/SOGoContactGCSFolder.h +++ b/SoObjects/Contacts/SOGoContactGCSFolder.h @@ -1,5 +1,5 @@ /* - Copyright (C) 2006-2015 Inverse inc. + Copyright (C) 2006-2016 Inverse inc. This file is part of SOGo. @@ -14,7 +14,7 @@ License for more details. You should have received a copy of the GNU Lesser General Public - License along with OGo; see the file COPYING. If not, write to the + License along with SOGo; see the file COPYING. If not, write to the Free Software Foundation, 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. */ @@ -22,6 +22,8 @@ #ifndef __Contacts_SOGoContactGCSFolder_H__ #define __Contacts_SOGoContactGCSFolder_H__ +#import + #import #import "SOGoFolder+CardDAV.h" @@ -34,7 +36,13 @@ NSString *baseCardDAVURL, *basePublicCardDAVURL; } - (void) fixupContactRecord: (NSMutableDictionary *) contactRecord; +- (EOQualifier *) qualifierForFilter: (NSString *) filter + onCriteria: (NSString *) criteria; - (NSDictionary *) lookupContactWithName: (NSString *) aName; +- (NSArray *) lookupContactsWithQualifier: (EOQualifier *) qualifier; +- (NSArray *) lookupContactsFields: (NSArray *) fields + withQualifier: (EOQualifier *) qualifier + andOrderings: (NSArray *) orderings; - (NSString *) cardDavURL; - (NSString *) publicCardDavURL; diff --git a/SoObjects/Contacts/SOGoContactGCSFolder.m b/SoObjects/Contacts/SOGoContactGCSFolder.m index 85a450967..bed89682f 100644 --- a/SoObjects/Contacts/SOGoContactGCSFolder.m +++ b/SoObjects/Contacts/SOGoContactGCSFolder.m @@ -1,5 +1,5 @@ /* - Copyright (C) 2006-2013-2015 Inverse inc. + Copyright (C) 2006-2016 Inverse inc. This file is part of SOGo. @@ -14,7 +14,7 @@ License for more details. You should have received a copy of the GNU Lesser General Public - License along with OGo; see the file COPYING. If not, write to the + License along with SOGo; see the file COPYING. If not, write to the Free Software Foundation, 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. */ @@ -26,6 +26,7 @@ #import #import #import +#import #import #import @@ -181,8 +182,8 @@ static NSArray *folderListingFields = nil; return obj; } -- (EOQualifier *) _qualifierForFilter: (NSString *) filter - onCriteria: (NSString *) criteria +- (EOQualifier *) qualifierForFilter: (NSString *) filter + onCriteria: (NSString *) criteria { NSString *qs; EOQualifier *qualifier; @@ -239,7 +240,7 @@ static NSArray *folderListingFields = nil; if ([data length]) [contactRecord setObject: data forKey: @"id"]; - // c_cn => fn + // c_cn data = [contactRecord objectForKey: @"c_cn"]; if (![data length]) { @@ -255,7 +256,7 @@ static NSArray *folderListingFields = nil; } } - // c_screenname => X-AIM + // c_screenname if (![contactRecord objectForKey: @"c_screenname"]) [contactRecord setObject: @"" forKey: @"c_screenname"]; @@ -268,9 +269,12 @@ static NSArray *folderListingFields = nil; [contactRecord setObject: [NSArray arrayWithObject: email] forKey: @"emails"]; } else - [contactRecord setObject: @"" forKey: @"c_mail"]; + { + [contactRecord setObject: @"" forKey: @"c_mail"]; + [contactRecord setObject: [NSArray array] forKey: @"emails"]; + } - // c_telephonenumber => phones + // c_telephonenumber => phones[] data = [contactRecord objectForKey: @"c_telephonenumber"]; if ([data length]) { @@ -279,7 +283,10 @@ static NSArray *folderListingFields = nil; [contactRecord setObject: [NSArray arrayWithObject: phonenumber] forKey: @"phones"]; } else - [contactRecord setObject: @"" forKey: @"c_telephonenumber"]; + { + [contactRecord setObject: @"" forKey: @"c_telephonenumber"]; + [contactRecord setObject: [NSArray array] forKey: @"phones"]; + } } - (NSArray *) _flattenedRecords: (NSArray *) records @@ -347,9 +354,9 @@ static NSArray *folderListingFields = nil; EOQualifier *qualifier; EOSortOrdering *ordering; - qualifier = [self _qualifierForFilter: filter onCriteria: criteria]; + qualifier = [self qualifierForFilter: filter onCriteria: criteria]; dbRecords = [[self ocsFolder] fetchFields: folderListingFields - matchingQualifier: qualifier]; + matchingQualifier: qualifier]; if ([dbRecords count] > 0) { @@ -370,6 +377,37 @@ static NSArray *folderListingFields = nil; return records; } +- (NSArray *) lookupContactsWithQualifier: (EOQualifier *) qualifier +{ + return [self lookupContactsFields: folderListingFields + withQualifier: qualifier + andOrderings: nil]; +} + +- (NSArray *) lookupContactsFields: (NSArray *) fields + withQualifier: (EOQualifier *) qualifier + andOrderings: (NSArray *) orderings +{ + NSArray *dbRecords, *records; + EOFetchSpecification *spec; + + spec = [EOFetchSpecification fetchSpecificationWithEntityName: [[self ocsFolder] folderName] + qualifier: qualifier + sortOrderings: orderings]; + + dbRecords = [[self ocsFolder] fetchFields: fields + fetchSpecification: spec + ignoreDeleted: YES]; + + if ([dbRecords count] > 0 && fields == folderListingFields) + records = [self _flattenedRecords: dbRecords]; + else + records = dbRecords; + + [self debugWithFormat:@"fetched %i records.", [records count]]; + return records; +} + - (NSDictionary *) davSQLFieldsTable { static NSMutableDictionary *davSQLFieldsTable = nil; diff --git a/UI/Contacts/UIxContactsListActions.h b/UI/Contacts/UIxContactsListActions.h index 2bd06c98c..cd0e33dac 100644 --- a/UI/Contacts/UIxContactsListActions.h +++ b/UI/Contacts/UIxContactsListActions.h @@ -31,9 +31,14 @@ @interface UIxContactsListActions : WODirectAction { NSDictionary *currentContact; + NSArray *contactInfos; + NSArray *sortedIDs; } +- (NSString *) cardDavURL; +- (NSString *) publicCardDavURL; + @end #endif /* __UIxContactsListActions_H__ */ diff --git a/UI/Contacts/UIxContactsListActions.m b/UI/Contacts/UIxContactsListActions.m index dccbcf960..4c6f82c19 100644 --- a/UI/Contacts/UIxContactsListActions.m +++ b/UI/Contacts/UIxContactsListActions.m @@ -1,5 +1,5 @@ /* - Copyright (C) 2006-2015 Inverse inc. + Copyright (C) 2006-2016 Inverse inc. This file is part of SOGo. @@ -20,6 +20,8 @@ */ #import +#import + #import #import #import @@ -32,6 +34,9 @@ #import #import +#import +#import + #import @@ -43,12 +48,18 @@ #import "UIxContactsListActions.h" +// The maximum number of headers to prefetch when querying the IDs list +#define headersPrefetchMaxSize 100 + @implementation UIxContactsListActions - (id) init { if ((self = [super init])) - contactInfos = nil; + { + contactInfos = nil; + sortedIDs = nil; + } return self; } @@ -56,6 +67,7 @@ - (void) dealloc { [contactInfos release]; + [sortedIDs release]; [super dealloc]; } @@ -99,7 +111,7 @@ [us setObject: contactSettings forKey: @"Contact"]; } [contactSettings setObject: [NSArray arrayWithObjects: [sort lowercaseString], [NSString stringWithFormat: @"%d", [ascending intValue]], nil] - forKey: @"SortingState"]; + forKey: @"SortingState"]; [us synchronize]; } } @@ -108,11 +120,12 @@ { id folder; NSString *ascending, *searchText, *valueText; - NSArray *results; - NSMutableArray *filteredContacts; + NSArray *results, *fields; + NSMutableArray *filteredContacts, *headers; NSDictionary *contact; BOOL excludeLists; NSComparisonResult ordering; + NSUInteger max, count; WORequest *rq; unsigned int i; @@ -158,6 +171,152 @@ { contactInfos = results; } + + // Convert array of dictionaries to array of arrays (lighter on the wire) + max = [contactInfos count]; + headers = [NSMutableArray arrayWithCapacity: max]; + if (max > 0) + { + count = 0; + fields = [[contactInfos objectAtIndex: 0] allKeys]; + [headers addObject: fields]; + while (count < max) + { + [headers addObject: [[contactInfos objectAtIndex: count] objectsForKeys: fields + notFoundMarker: [NSNull null]]]; + count++; + } + } + + contactInfos = headers; + [contactInfos retain]; + } + + return contactInfos; +} + +- (NSArray *) sortedIDs +{ + id folder; + NSString *ascending, *searchText, *valueText; + NSArray *fields, *records; + NSDictionary *record; + NSEnumerator *recordsList; + NSMutableArray *ids; + BOOL excludeLists; + EOKeyValueQualifier *kvQualifier; + EOSortOrdering *ordering; + EOQualifier *qualifier; + WORequest *rq; + SEL compare; + + folder = [self clientObject]; + + if (!sortedIDs && [folder isKindOfClass: [SOGoContactGCSFolder class]]) + { + fields = [NSArray arrayWithObjects: @"c_name", nil]; + rq = [context request]; + qualifier = nil; + + // ORDER BY clause + ascending = [rq formValueForKey: @"asc"]; + if (![ascending length] || [ascending boolValue]) + compare = EOCompareAscending; + else + compare = EOCompareDescending; + ordering = [EOSortOrdering sortOrderingWithKey: [self sortKey] + selector: compare]; + + // WHERE clause + searchText = [rq formValueForKey: @"search"]; + if ([searchText length] > 0) + { + valueText = [rq formValueForKey: @"value"]; + qualifier = [(SOGoContactGCSFolder *) folder qualifierForFilter: valueText + onCriteria: searchText]; + } + excludeLists = [[rq formValueForKey: @"excludeLists"] boolValue]; + if (excludeLists) + { + kvQualifier = [[EOKeyValueQualifier alloc] + initWithKey: @"c_component" + operatorSelector: EOQualifierOperatorNotEqual + value: @"vlist"]; + [kvQualifier autorelease]; + if (qualifier) + qualifier = [[EOAndQualifier alloc] initWithQualifiers: kvQualifier, qualifier, nil]; + else + qualifier = kvQualifier; + } + + // Perform lookup + records = [(SOGoContactGCSFolder *) folder lookupContactsFields: fields + withQualifier: qualifier + andOrderings: [NSArray arrayWithObject: ordering]]; + + // Convert records to an array of strings (c_name) + ids = [NSMutableArray arrayWithCapacity: [records count]]; + recordsList = [records objectEnumerator]; + while ((record = [recordsList nextObject])) + [ids addObject: [record objectForKey: @"c_name"]]; + + [sortedIDs release]; + sortedIDs = ids; + [sortedIDs retain]; + } + + return sortedIDs; +} + +- (NSArray *) getHeadersForIDs: (NSArray *) ids +{ + id folder; + NSArray *results, *fields; + NSEnumerator *idsList; + NSMutableArray *qualifiers, *headers; + NSString *contactID; + EOKeyValueQualifier *kvQualifier; + EOQualifier *orQualifier; + NSUInteger max, count; + + folder = [self clientObject]; + + if (!contactInfos && [ids count] && [folder isKindOfClass: [SOGoContactGCSFolder class]]) + { + qualifiers = [NSMutableArray arrayWithCapacity: [ids count]]; + idsList = [ids objectEnumerator]; + + while ((contactID = [idsList nextObject])) + { + kvQualifier = [[EOKeyValueQualifier alloc] + initWithKey: @"c_name" + operatorSelector: EOQualifierOperatorEqual + value: contactID]; + [kvQualifier autorelease]; + [qualifiers addObject: kvQualifier]; + } + + orQualifier = [[EOOrQualifier alloc] initWithQualifierArray: qualifiers]; + [orQualifier autorelease]; + + results = [(SOGoContactGCSFolder *) folder lookupContactsWithQualifier: orQualifier]; + max = [results count]; + headers = [NSMutableArray arrayWithCapacity: max]; + if (max > 0) + { + count = 0; + fields = [[results objectAtIndex: 0] allKeys]; + [headers addObject: fields]; + while (count < max) + { + [headers addObject: [[results objectAtIndex: count] objectsForKeys: fields + notFoundMarker: [NSNull null]]]; + count++; + } + } + + [contactInfos release]; + contactInfos = headers; [contactInfos retain]; } @@ -172,6 +331,7 @@ * @apiExample {curl} Example usage: * curl -i http://localhost/SOGo/so/sogo1/Contacts/personal/view?search=name_or_address\&value=Bob * + * @apiParam {Boolean} [partial] Descending sort when false. Defaults to true (ascending). * @apiParam {Boolean} [asc] Descending sort when false. Defaults to true (ascending). * @apiParam {String} [sort] Sort field. Either c_cn, c_mail, c_screenname, c_o, or c_telephonenumber. * @apiParam {String} [search] Field criteria. Either name_or_address, category, or organization. @@ -201,24 +361,87 @@ - (id ) contactsListAction { id result; - NSDictionary *data; - NSArray *contactsList; + id folder; + NSMutableDictionary *data; + NSArray *ids, *partialIds, *headers; + NSRange range; + NSString *partial; - contactsList = [self contactInfos]; + folder = [self clientObject]; + data = [NSMutableDictionary dictionaryWithObjectsAndKeys: + [folder nameInContainer], @"id", + [self cardDavURL], @"cardDavURL", + [self publicCardDavURL], @"publicCardDavURL", + nil]; + partial = [[context request] formValueForKey: @"partial"]; - data = [NSDictionary dictionaryWithObjectsAndKeys: - [[self clientObject] nameInContainer], @"id", - [self cardDavURL], @"cardDavURL", - [self publicCardDavURL], @"publicCardDavURL", - contactsList, @"cards", - nil]; + if ([partial intValue] && [folder isKindOfClass: [SOGoContactGCSFolder class]]) + { + // Only save sort state when performing a partial listing + [self saveSortValue]; + + // Fetch all sorted IDs + ids = [self sortedIDs]; + + // Fetch the first X entries + if ([ids count] > headersPrefetchMaxSize) + { + range = NSMakeRange(0, headersPrefetchMaxSize); + partialIds = [ids subarrayWithRange: range]; + } + else + { + partialIds = ids; + } + headers = [self getHeadersForIDs: partialIds]; + + if (ids) + [data setObject: ids forKey: @"ids"]; + if (headers) + [data setObject: headers forKey: @"headers"]; + } + else + { + headers = [self contactInfos]; + if (headers) + [data setObject: headers forKey: @"headers"]; + } result = [self responseWithStatus: 200 - andString: [data jsonRepresentation]]; + andJSONRepresentation: data]; return result; } +- (id ) getHeadersAction +{ + NSArray *ids, *headers; + NSDictionary *data; + WORequest *request; + WOResponse *response; + + request = [context request]; + data = [[request contentAsString] objectFromJSONString]; + if (![[data objectForKey: @"ids"] isKindOfClass: [NSArray class]] || + [[data objectForKey: @"ids"] count] == 0) + { + data = [NSDictionary dictionaryWithObjectsAndKeys: + @"No ID specified", @"message", nil]; + response = [self responseWithStatus: 404 /* Not Found */ + andJSONRepresentation: data]; + + return response; + } + + ids = [data objectForKey: @"ids"]; + headers = [self getHeadersForIDs: ids]; + + response = [self responseWithStatus: 200 + andJSONRepresentation: headers]; + + return response; +} + - (id ) contactSearchAction { id result; diff --git a/UI/Contacts/product.plist b/UI/Contacts/product.plist index 158e24815..4a8112964 100644 --- a/UI/Contacts/product.plist +++ b/UI/Contacts/product.plist @@ -78,6 +78,11 @@ actionClass = "UIxContactsListActions"; actionName = "contactsList"; }; + headers = { + protectedBy = "View"; + actionClass = "UIxContactsListActions"; + actionName = "getHeaders"; + }; // contacts = { // protectedBy = "View"; // actionClass = "UIxContactsListActions"; diff --git a/UI/Templates/ContactsUI/UIxContactFoldersView.wox b/UI/Templates/ContactsUI/UIxContactFoldersView.wox index 05566f436..faece92cb 100644 --- a/UI/Templates/ContactsUI/UIxContactFoldersView.wox +++ b/UI/Templates/ContactsUI/UIxContactFoldersView.wox @@ -356,9 +356,9 @@ - + - {{addressbook.selectedFolder.cards.length}} + {{addressbook.selectedFolder.$cards.length}} @@ -367,19 +367,20 @@ - + - {{addressbook.selectedFolder.cards.length}} + {{addressbook.selectedFolder.$cards.length}} - +
= 0 && index < this.$cards.length) { + card = this.$cards[index]; + this.$lastVisibleIndex = Math.max(0, index - 3); // Magic number is NUM_EXTRA from virtual-repeater.js + + if (this.$loadCard(card)) + return card; + } + return null; + }; + + /** + * @function $loadCard + * @memberof AddressBook.prototype + * @desc Check if the card is loaded and in any case, fetch more cards headers from the server. + * @returns true if the card metadata are already fetched + */ + AddressBook.prototype.$loadCard = function(card) { + var cardId = card.id, + startIndex = this.idsMap[cardId], + endIndex, + index, + max = this.$cards.length, + loaded = false, + ids, + futureHeadersData; + + if (angular.isUndefined(this.ids) && card.id) { + loaded = true; + } + else if (angular.isDefined(startIndex) && startIndex < this.$cards.length) { + // Index is valid + if (card.$loaded != AddressBook.$Card.STATUS.NOT_LOADED) { + // Card headers are loaded or data is coming + loaded = true; + } + + // Preload more headers if possible + endIndex = Math.min(startIndex + AddressBook.PRELOAD.LOOKAHEAD, max - 1); + if (this.$cards[endIndex].$loaded != AddressBook.$Card.STATUS.NOT_LOADED) { + index = Math.max(startIndex - AddressBook.PRELOAD.LOOKAHEAD, 0); + if (this.$cards[index].$loaded != AddressBook.$Card.STATUS.LOADED) { + // Previous cards not loaded; preload more headers further up + endIndex = startIndex; + startIndex = Math.max(startIndex - AddressBook.PRELOAD.SIZE, 0); + } + } + else + // Next cards not load; preload more headers further down + endIndex = Math.min(startIndex + AddressBook.PRELOAD.SIZE, max - 1); + + if (this.$cards[startIndex].$loaded == AddressBook.$Card.STATUS.NOT_LOADED || + this.$cards[endIndex].$loaded == AddressBook.$Card.STATUS.NOT_LOADED) { + + for (ids = []; startIndex < endIndex && startIndex < max; startIndex++) { + if (this.$cards[startIndex].$loaded != AddressBook.$Card.STATUS.NOT_LOADED) { + // Card at this index is already loaded; increase the end index + endIndex++; + } + else { + // Card at this index will be loaded + ids.push(this.$cards[startIndex].id); + this.$cards[startIndex].$loaded = AddressBook.$Card.STATUS.LOADING; + } + } + + AddressBook.$log.debug('Loading Ids ' + ids.join(' ') + ' (' + ids.length + ' cards)'); + if (ids.length > 0) { + futureHeadersData = AddressBook.$$resource.post(this.id, 'headers', {ids: ids}); + this.$unwrapHeaders(futureHeadersData); + } + } + } + return loaded; + }; + /** * @function isSelectedCard * @memberof AddressBook.prototype @@ -263,8 +370,8 @@ var count; count = 0; - if (this.cards) { - count = (_.filter(this.cards, function(card) { return card.selected; })).length; + if (this.$cards) { + count = (_.filter(this.$cards, function(card) { return card.selected; })).length; } return count; }; @@ -312,25 +419,27 @@ * @returns a collection of Cards instances */ AddressBook.prototype.$filter = function(search, options, excludedCards) { - var _this = this, query; + var _this = this, query, + dry = options && options.dry; - if (!options || !options.dry) { - this.$isLoading = true; - query = AddressBook.$query; - } - else if (options.dry) { + if (dry) { // Don't keep a copy of the query in dry mode query = angular.copy(AddressBook.$query); } + else { + this.$isLoading = true; + query = AddressBook.$query; + if (!this.isRemote) query.partial = 1; + } return AddressBook.$Preferences.ready().then(function() { if (options) { angular.extend(query, options); - if (options.dry) { + if (dry) { if (!search) { // No query specified - _this.$cards = []; - return AddressBook.$q.when(_this.$cards); + _this.$$cards = []; + return AddressBook.$q.when(_this.$$cards); } } } @@ -339,54 +448,73 @@ query.value = search; return _this.$id().then(function(addressbookId) { - return AddressBook.$$resource.fetch(addressbookId, 'view', query); - }).then(function(response) { - var results, cards, card, index, - compareIds = function(data) { - return _this.id == data.id; - }; - if (options && options.dry) { - // Don't keep a copy of the resulting cards. - // This is usefull when doing autocompletion. - cards = _this.$cards; - } - else { - cards = _this.cards; - } - if (excludedCards) { - // Remove excluded cards from results - results = _.filter(response.cards, function(card) { - return _.isUndefined(_.find(excludedCards, compareIds, card)); + var futureData = AddressBook.$$resource.fetch(addressbookId, 'view', query); + + if (dry) { + futureData.then(function(response) { + var results, headers, card, index, fields, idFieldIndex, + cards = _this.$$cards, + compareIds = function(card) { + return this == card.id; + }; + + // First entry of 'headers' are keys + fields = _.invoke(response.headers[0], 'toLowerCase'); + idFieldIndex = fields.indexOf('id'); + response.headers.splice(0, 1); + + if (excludedCards) + // Remove excluded cards from results + results = _.filter(response.ids, function(id) { + return _.isUndefined(_.find(excludedCards, compareIds, id)); + }); + else + results = response.ids; + + // Remove cards that no longer match the search query + for (index = cards.length - 1; index >= 0; index--) { + card = cards[index]; + if (_.isUndefined(_.find(results, compareIds, card.id))) { + cards.splice(index, 1); + } + } + + // Add new cards matching the search query + _.each(results, function(cardId, index) { + if (_.isUndefined(_.find(cards, compareIds, cardId))) { + var data = { id: cardId }; + var card = new AddressBook.$Card(data, search); + cards.splice(index, 0, card); + } + }); + + // Respect the order of the results + _.each(results, function(cardId, index) { + var oldIndex, removedCards; + if (cards[index].id != cardId) { + oldIndex = _.findIndex(cards, compareIds, cardId); + removedCards = cards.splice(oldIndex, 1); + cards.splice(index, 0, removedCards[0]); + } + }); + + // Extend Card objects with received headers + _.each(response.headers, function(data) { + var card, index = _.findIndex(cards, compareIds, data[idFieldIndex]); + if (index > -1) { + card = _.object(fields, data); + cards[index].init(card, search); + } + }); + + _this.$isLoading = false; + return cards; }); } else { - results = response.cards; + // Unwrap promise and instantiate or extend Cards objets + _this.$unwrap(futureData); } - // Remove cards that no longer match the search query - for (index = cards.length - 1; index >= 0; index--) { - card = cards[index]; - if (_.isUndefined(_.find(results, compareIds, card))) { - cards.splice(index, 1); - } - } - // Add new cards matching the search query - _.each(results, function(data, index) { - if (_.isUndefined(_.find(cards, compareIds, data))) { - var card = new AddressBook.$Card(data, search); - cards.splice(index, 0, card); - } - }); - // Respect the order of the results - _.each(results, function(data, index) { - var oldIndex, removedCards; - if (cards[index].id != data.id) { - oldIndex = _.findIndex(cards, compareIds, data); - removedCards = cards.splice(oldIndex, 1); - cards.splice(index, 0, removedCards[0]); - } - }); - _this.$isLoading = false; - return cards; }); }); }; @@ -447,7 +575,7 @@ var _this = this; return AddressBook.$$resource.post(this.id, 'batchDelete', {uids: uids}).then(function() { - _this.cards = _.difference(_this.cards, cards); + _this.$cards = _.difference(_this.$cards, cards); }); }; @@ -485,7 +613,7 @@ return this.$id().then(function(addressbookId) { var fullCard, - cachedCard = _.find(_this.cards, function(data) { + cachedCard = _.find(_this.$cards, function(data) { return cardId == data.id; }); @@ -512,22 +640,66 @@ AddressBook.prototype.$unwrap = function(futureAddressBookData) { var _this = this; + this.$isLoading = true; + // Expose and resolve the promise - this.$futureAddressBookData = futureAddressBookData.then(function(data) { + this.$futureAddressBookData = futureAddressBookData.then(function(response) { return AddressBook.$timeout(function() { + var headers; + + if (!response.ids || _this.$topIndex > response.ids.length - 1) + _this.$topIndex = 0; + // Extend AddressBook instance from data of addressbooks list. // Will inherit attributes such as isEditable and isRemote. angular.forEach(AddressBook.$findAll(), function(o, i) { - if (o.id == data.id) { + if (o.id == response.id) { angular.extend(_this, o); } }); + // Extend AddressBook instance with received data - _this.init(data); - // Instanciate Card objects - angular.forEach(_this.cards, function(o, i) { - _this.cards[i] = new AddressBook.$Card(o); - }); + _this.init(response); + + if (_this.ids) { + AddressBook.$log.debug('unwrapping ' + _this.ids.length + ' cards'); + + // Instanciate Card objects + _.reduce(_this.ids, function(cards, card, i) { + var data = { id: card }; + + // Build map of ID <=> index + _this.idsMap[data.id] = i; + + cards.push(new AddressBook.$Card(data)); + + return cards; + }, _this.$cards); + } + + if (response.headers) { + // First entry of 'headers' are keys + headers = _.invoke(response.headers[0], 'toLowerCase'); + response.headers.splice(0, 1); + + if (_this.ids) { + // Extend Card objects with received headers + _.each(response.headers, function(data) { + var o = _.object(headers, data), + i = _this.idsMap[o.id]; + _this.$cards[i].init(o); + }); + } + else { + // Instanciate Card objects + _this.$cards = []; + angular.forEach(response.headers, function(data) { + var o = _.object(headers, data); + _this.$cards.push(new AddressBook.$Card(o)); + }); + } + } + // Instanciate Acl object _this.$acl = new AddressBook.$$Acl('Contacts/' + _this.id); @@ -535,6 +707,8 @@ _this.$isLoading = false; + AddressBook.$log.debug('addressbook ' + _this.id + ' ready'); + return _this; }); }, function(data) { @@ -547,6 +721,34 @@ }); }; + /** + * @function $unwrapHeaders + * @memberof AddressBook.prototype + * @desc Unwrap a promise and extend matching Card objects with received data. + * @param {promise} futureHeadersData - a promise of the metadata of some cards + */ + AddressBook.prototype.$unwrapHeaders = function(futureHeadersData) { + var _this = this; + + futureHeadersData.then(function(data) { + AddressBook.$timeout(function() { + var headers, j; + if (data.length > 0) { + // First entry of 'headers' are keys + headers = _.invoke(data[0], 'toLowerCase'); + data.splice(0, 1); + _.each(data, function(cardHeaders) { + cardHeaders = _.object(headers, cardHeaders); + j = _this.idsMap[cardHeaders.id]; + if (angular.isDefined(j)) { + _this.$cards[j].init(cardHeaders); + } + }); + } + }); + }); + }; + /** * @function $omit * @memberof AddressBook.prototype @@ -557,7 +759,7 @@ var addressbook = {}; angular.forEach(this, function(value, key) { if (key != 'constructor' && - key != 'cards' && + key != 'ids' && key[0] != '$') { addressbook[key] = value; } diff --git a/UI/WebServerResources/js/Contacts/Card.service.js b/UI/WebServerResources/js/Contacts/Card.service.js index db34dfa56..87967a5d0 100644 --- a/UI/WebServerResources/js/Contacts/Card.service.js +++ b/UI/WebServerResources/js/Contacts/Card.service.js @@ -38,8 +38,9 @@ * @desc The factory we'll use to register with Angular. * @returns the Card constructor */ - Card.$factory = ['$timeout', 'sgSettings', 'Resource', 'Preferences', 'Gravatar', function($timeout, Settings, Resource, Preferences, Gravatar) { + Card.$factory = ['$timeout', 'sgSettings', 'sgCard_STATUS', 'Resource', 'Preferences', 'Gravatar', function($timeout, Settings, Card_STATUS, Resource, Preferences, Gravatar) { angular.extend(Card, { + STATUS: Card_STATUS, $$resource: new Resource(Settings.activeUser('folderURL') + 'Contacts', Settings.activeUser()), $timeout: $timeout, $gravatar: Gravatar, @@ -68,6 +69,11 @@ angular.module('SOGo.ContactsUI', ['SOGo.Common', 'SOGo.PreferencesUI']); } angular.module('SOGo.ContactsUI') + .constant('sgCard_STATUS', { + NOT_LOADED: 0, + LOADING: 1, + LOADED: 2 + }) .factory('Card', Card.$factory); /** @@ -138,7 +144,9 @@ this.$$email = this.$preferredEmail(partial); if (!this.$$image) this.$$image = this.image || Card.$gravatar(this.$preferredEmail(partial), 32, Card.$alternateAvatar, {no_404: true}); - this.selected = false; + if (this.isgroup) + this.c_component = 'vlist'; + this.$loaded = angular.isDefined(this.c_name)? Card.STATUS.LOADED : Card.STATUS.NOT_LOADED; // An empty attribute to trick md-autocomplete when adding attendees from the appointment editor this.empty = ' '; @@ -449,6 +457,9 @@ Card.prototype.$unwrap = function(futureCardData) { var _this = this; + // Card is not loaded yet + this.$loaded = Card.STATUS.LOADING; + // Expose the promise this.$futureCardData = futureCardData.then(function(data) { _this.init(data); @@ -462,8 +473,11 @@ _this.birthday = new Date(_this.birthday * 1000); _this.$birthday = Card.$Preferences.$mdDateLocaleProvider.formatDate(_this.birthday); } + // Mark card as loaded + _this.$loaded = Card.STATUS.LOADED; // Make a copy of the data for an eventual reset _this.$shadowData = _this.$omit(true); + return _this; }); }; diff --git a/UI/WebServerResources/js/Contacts/CardController.js b/UI/WebServerResources/js/Contacts/CardController.js index 6811d2c5a..73b300fa1 100644 --- a/UI/WebServerResources/js/Contacts/CardController.js +++ b/UI/WebServerResources/js/Contacts/CardController.js @@ -28,7 +28,6 @@ vm.addPhone = addPhone; vm.addUrl = addUrl; vm.addAddress = addAddress; - vm.addMember = addMember; vm.userFilter = userFilter; vm.save = save; vm.close = close; @@ -71,13 +70,9 @@ var i = vm.card.$addAddress('', '', '', '', '', '', '', ''); focus('address_' + i); } - function addMember() { - var i = vm.card.$addMember(''); - focus('ref_' + i); - } function userFilter($query, excludedCards) { AddressBook.selectedFolder.$filter($query, {dry: true, excludeLists: true}, excludedCards); - return AddressBook.selectedFolder.$cards; + return AddressBook.selectedFolder.$$cards; } function save(form) { if (form.$valid) { @@ -120,7 +115,7 @@ } function confirmDelete(card) { Dialog.confirm(l('Warning'), - l('Are you sure you want to delete the card of %{0}?', card.$fullname()), + l('Are you sure you want to delete the card of %{0}?', '' + card.$fullname() + ''), { ok: l('Delete') }) .then(function() { // User confirmed the deletion