/* * Library.j * SmartFoldersDemo * * The application model. Holds all people plus the three source list * sections (Library, Groups, Smart Folders), persists itself to * CPUserDefaults (local storage) and observes every Person via KVO so that * edits made anywhere in the UI are noticed, saved and reflected in the * smart folder badges. * * Change notifications: * - structural changes (people added/removed, group membership, folders * added/removed/renamed/reordered/edited) are posted synchronously; * - property edits on a Person are coalesced and posted on the next run * loop turn. */ @import @import "Person.j" LibraryDidChangeNotification = @"LibraryDidChangeNotification"; LibraryChangeKindKey = @"kind"; LibraryChangeStructural = @"structural"; LibraryChangePersonEdited = @"personEdited"; PeopleDragType = @"SmartFoldersDemoPeopleDragType"; SourceItemDragType = @"SmartFoldersDemoSourceItemDragType"; LibraryDepartments = [@"Engineering", @"Design", @"Marketing", @"Sales", @"Support", @"Finance", @"Legal"]; LibraryCities = [@"Berlin", @"Hamburg", @"Munich", @"Zürich", @"Vienna", @"Paris", @"London", @"Lisbon", @"New York", @"San Francisco", @"Tokyo"]; var LibraryDefaultsKey = @"SmartFoldersDemo.Library.v1", ObservedPersonKeys = [@"firstName", @"lastName", @"email", @"department", @"city", @"birthDate", @"salary", @"isManager"], SourceItemUIDCounter = 0; // -------------------------------------------------------------------------------- // Source list items // -------------------------------------------------------------------------------- @implementation SourceItem : CPObject { CPString uid @accessors; CPString name @accessors; } - (id)initWithName:(CPString)aName { if (self = [super init]) { uid = [self className] + "-" + (++SourceItemUIDCounter); name = aName; } return self; } - (BOOL)isSection { return NO; } - (BOOL)isSelectable { return YES; } // Can be renamed, deleted and reordered by the user. - (BOOL)isUserItem { return NO; } @end @implementation SourceSection : SourceItem { CPMutableArray children @accessors; } - (id)initWithName:(CPString)aName { if (self = [super initWithName:aName]) children = []; return self; } - (BOOL)isSection { return YES; } - (BOOL)isSelectable { return NO; } @end @implementation AllPeopleItem : SourceItem @end @implementation LibraryGroup : SourceItem { CPMutableArray memberIDs @accessors; } - (id)initWithName:(CPString)aName memberIDs:(CPArray)theIDs { if (self = [super initWithName:aName]) memberIDs = [CPMutableArray arrayWithArray:theIDs || []]; return self; } - (id)initWithDictionary:(CPDictionary)aDictionary { return [self initWithName:[aDictionary objectForKey:@"name"] || @"Group" memberIDs:[aDictionary objectForKey:@"members"] || []]; } - (CPDictionary)dictionaryRepresentation { return [CPDictionary dictionaryWithObjectsAndKeys:name, @"name", [memberIDs copy], @"members"]; } - (BOOL)isUserItem { return YES; } @end @implementation SmartFolder : SourceItem { CPPredicate predicate @accessors; } - (id)initWithName:(CPString)aName predicate:(CPPredicate)aPredicate { if (self = [super initWithName:aName]) predicate = aPredicate; return self; } - (id)initWithDictionary:(CPDictionary)aDictionary { var format = [aDictionary objectForKey:@"predicate"], aPredicate = nil; try { if (format) aPredicate = [CPPredicate predicateWithFormat:format]; } catch (e) { CPLog.warn("SmartFoldersDemo: could not parse stored predicate '" + format + "': " + e); } return [self initWithName:[aDictionary objectForKey:@"name"] || @"Smart Folder" predicate:aPredicate || [CPPredicate predicateWithValue:YES]]; } - (CPDictionary)dictionaryRepresentation { return [CPDictionary dictionaryWithObjectsAndKeys:name, @"name", [predicate predicateFormat], @"predicate"]; } - (BOOL)isUserItem { return YES; } @end // -------------------------------------------------------------------------------- // Library // -------------------------------------------------------------------------------- @implementation Library : CPObject { CPMutableArray _people; Object _peopleByUID; SourceSection _librarySection; SourceSection _groupsSection; SourceSection _smartFoldersSection; AllPeopleItem _allPeopleItem; CPArray _sections; BOOL _editNotificationScheduled; BOOL _saveScheduled; } - (id)init { if (self = [super init]) { _people = []; _peopleByUID = {}; _allPeopleItem = [[AllPeopleItem alloc] initWithName:@"All People"]; _librarySection = [[SourceSection alloc] initWithName:@"LIBRARY"]; [[_librarySection children] addObject:_allPeopleItem]; _groupsSection = [[SourceSection alloc] initWithName:@"GROUPS"]; _smartFoldersSection = [[SourceSection alloc] initWithName:@"SMART FOLDERS"]; _sections = [_librarySection, _groupsSection, _smartFoldersSection]; if (![self _loadFromDefaults]) [self _populateWithSampleData]; } return self; } // ---------------------------------------------------------------- Accessors - (CPArray)people { return _people; } - (CPArray)sections { return _sections; } - (AllPeopleItem)allPeopleItem { return _allPeopleItem; } - (SourceSection)groupsSection { return _groupsSection; } - (SourceSection)smartFoldersSection { return _smartFoldersSection; } - (CPArray)groups { return [_groupsSection children]; } - (CPArray)smartFolders { return [_smartFoldersSection children]; } - (CPArray)childrenOfItem:(id)anItem { if (anItem == nil) return _sections; if ([anItem isSection]) return [anItem children]; return []; } - (SourceSection)sectionForItem:(id)anItem { if ([anItem isKindOfClass:[AllPeopleItem class]]) return _librarySection; if ([anItem isKindOfClass:[LibraryGroup class]]) return _groupsSection; if ([anItem isKindOfClass:[SmartFolder class]]) return _smartFoldersSection; return nil; } - (BOOL)containsItem:(id)anItem { if (!anItem) return NO; if ([_sections indexOfObjectIdenticalTo:anItem] !== CPNotFound) return YES; var section = [self sectionForItem:anItem]; return section && [[section children] indexOfObjectIdenticalTo:anItem] !== CPNotFound; } - (id)itemWithUID:(CPString)aUID { for (var i = 0; i < _sections.length; i++) { var section = _sections[i], children = [section children]; if ([section uid] === aUID) return section; for (var j = 0; j < children.length; j++) if ([children[j] uid] === aUID) return children[j]; } return nil; } - (Person)personWithUID:(CPString)aUID { return _peopleByUID[aUID] || nil; } - (CPArray)peopleWithUIDs:(CPArray)theUIDs { var result = []; for (var i = 0; i < theUIDs.length; i++) { var person = _peopleByUID[theUIDs[i]]; if (person) result.push(person); } return result; } // The unfiltered content for a source item (what goes into the array controller). - (CPArray)baseContentForItem:(id)anItem { if ([anItem isKindOfClass:[LibraryGroup class]]) return [self peopleWithUIDs:[anItem memberIDs]]; if ([anItem isKindOfClass:[AllPeopleItem class]] || [anItem isKindOfClass:[SmartFolder class]]) return _people; return []; } - (CPPredicate)predicateForItem:(id)anItem { if ([anItem isKindOfClass:[SmartFolder class]]) return [anItem predicate]; return nil; } - (CPArray)peopleMatchingPredicate:(CPPredicate)aPredicate { if (!aPredicate) return _people; try { return [_people filteredArrayUsingPredicate:aPredicate]; } catch (e) { CPLog.warn("SmartFoldersDemo: predicate evaluation failed: " + e); return nil; } } - (int)countForItem:(id)anItem { if ([anItem isSection]) return CPNotFound; var base = [self baseContentForItem:anItem], predicate = [self predicateForItem:anItem]; if (!predicate) return [base count]; var matches = [self peopleMatchingPredicate:predicate]; return matches ? [matches count] : CPNotFound; } // ---------------------------------------------------------------- People mutation - (Person)createPerson { var person = [[Person alloc] init]; [person setFirstName:@"New"]; [person setLastName:@"Person"]; [person setEmail:@"new.person@example.com"]; [person setCity:LibraryCities[0]]; [self addPeople:[person]]; return person; } - (void)addPeople:(CPArray)thePeople { if (![thePeople count]) return; for (var i = 0; i < thePeople.length; i++) [self _insertPerson:thePeople[i]]; [self _noteStructuralChange]; } - (void)removePeople:(CPArray)thePeople { if (![thePeople count]) return; var uids = []; for (var i = 0; i < thePeople.length; i++) { var person = thePeople[i]; if (!_peopleByUID[[person uid]]) continue; [self _stopObservingPerson:person]; [_people removeObjectIdenticalTo:person]; delete _peopleByUID[[person uid]]; uids.push([person uid]); } var groups = [self groups]; for (var i = 0; i < groups.length; i++) [[groups[i] memberIDs] removeObjectsInArray:uids]; [self _noteStructuralChange]; } - (void)addPeople:(CPArray)thePeople toGroup:(LibraryGroup)aGroup { var members = [aGroup memberIDs], changed = NO; for (var i = 0; i < thePeople.length; i++) { var uid = [thePeople[i] uid]; if (![members containsObject:uid]) { [members addObject:uid]; changed = YES; } } if (changed) [self _noteStructuralChange]; } - (void)removePeople:(CPArray)thePeople fromGroup:(LibraryGroup)aGroup { [[aGroup memberIDs] removeObjectsInArray:[thePeople valueForKey:@"uid"]]; [self _noteStructuralChange]; } // ---------------------------------------------------------------- Source item mutation - (LibraryGroup)addGroupWithName:(CPString)aName people:(CPArray)thePeople { var group = [[LibraryGroup alloc] initWithName:aName memberIDs:[thePeople valueForKey:@"uid"] || []]; [[_groupsSection children] addObject:group]; [self _noteStructuralChange]; return group; } - (SmartFolder)addSmartFolderWithName:(CPString)aName predicate:(CPPredicate)aPredicate { var folder = [[SmartFolder alloc] initWithName:aName predicate:aPredicate]; [[_smartFoldersSection children] addObject:folder]; [self _noteStructuralChange]; return folder; } - (void)updateSmartFolder:(SmartFolder)aFolder name:(CPString)aName predicate:(CPPredicate)aPredicate { [aFolder setName:aName]; [aFolder setPredicate:aPredicate]; [self _noteStructuralChange]; } - (void)renameItem:(SourceItem)anItem to:(CPString)aName { if (![anItem isUserItem] || ![aName length]) return; [anItem setName:aName]; [self _noteStructuralChange]; } - (void)removeItem:(SourceItem)anItem { var section = [self sectionForItem:anItem]; if (![anItem isUserItem] || !section) return; [[section children] removeObjectIdenticalTo:anItem]; [self _noteStructuralChange]; } // Moves a user item to a new index inside its own section. // anIndex is the proposed insertion index *before* removal (outline view semantics). - (void)moveItem:(SourceItem)anItem toIndex:(int)anIndex { var section = [self sectionForItem:anItem], children = [section children], oldIndex = [children indexOfObjectIdenticalTo:anItem]; if (oldIndex === CPNotFound) return; if (anIndex > oldIndex) anIndex--; anIndex = MAX(0, MIN(anIndex, [children count] - 1)); if (anIndex === oldIndex) return; [children removeObjectAtIndex:oldIndex]; [children insertObject:anItem atIndex:anIndex]; [self _noteStructuralChange]; } - (void)resetToSampleData { var people = [_people copy]; for (var i = 0; i < people.length; i++) [self _stopObservingPerson:people[i]]; _people = []; _peopleByUID = {}; [[_groupsSection children] removeAllObjects]; [[_smartFoldersSection children] removeAllObjects]; [[CPUserDefaults standardUserDefaults] removeObjectForKey:LibraryDefaultsKey]; [self _populateWithSampleData]; [self _noteStructuralChange]; } // ---------------------------------------------------------------- KVO - (void)_insertPerson:(Person)aPerson { if (_peopleByUID[[aPerson uid]]) return; _people.push(aPerson); _peopleByUID[[aPerson uid]] = aPerson; for (var i = 0; i < ObservedPersonKeys.length; i++) [aPerson addObserver:self forKeyPath:ObservedPersonKeys[i] options:CPKeyValueObservingOptionNew context:nil]; } - (void)_stopObservingPerson:(Person)aPerson { for (var i = 0; i < ObservedPersonKeys.length; i++) [aPerson removeObserver:self forKeyPath:ObservedPersonKeys[i]]; } - (void)observeValueForKeyPath:(CPString)aKeyPath ofObject:(id)anObject change:(CPDictionary)aChange context:(id)aContext { if (_editNotificationScheduled) return; _editNotificationScheduled = YES; [CPTimer scheduledTimerWithTimeInterval:0 target:self selector:@selector(_postEditNotification:) userInfo:nil repeats:NO]; } - (void)_postEditNotification:(CPTimer)aTimer { _editNotificationScheduled = NO; [self _scheduleSave]; [[CPNotificationCenter defaultCenter] postNotificationName:LibraryDidChangeNotification object:self userInfo:[CPDictionary dictionaryWithObject:LibraryChangePersonEdited forKey:LibraryChangeKindKey]]; } - (void)_noteStructuralChange { [self _scheduleSave]; [[CPNotificationCenter defaultCenter] postNotificationName:LibraryDidChangeNotification object:self userInfo:[CPDictionary dictionaryWithObject:LibraryChangeStructural forKey:LibraryChangeKindKey]]; } // ---------------------------------------------------------------- Persistence - (void)_scheduleSave { if (_saveScheduled) return; _saveScheduled = YES; [CPTimer scheduledTimerWithTimeInterval:0.25 target:self selector:@selector(_saveTimerFired:) userInfo:nil repeats:NO]; } - (void)_saveTimerFired:(CPTimer)aTimer { _saveScheduled = NO; [self save]; } - (void)save { var people = [], groups = [], folders = [], groupItems = [self groups], folderItems = [self smartFolders]; for (var i = 0; i < _people.length; i++) people.push([_people[i] dictionaryRepresentation]); for (var i = 0; i < groupItems.length; i++) groups.push([groupItems[i] dictionaryRepresentation]); for (var i = 0; i < folderItems.length; i++) folders.push([folderItems[i] dictionaryRepresentation]); var defaults = [CPUserDefaults standardUserDefaults], dictionary = [CPDictionary dictionaryWithObjectsAndKeys: 1, @"version", people, @"people", groups, @"groups", folders, @"smartFolders"]; [defaults setObject:dictionary forKey:LibraryDefaultsKey]; [defaults synchronize]; } - (BOOL)_loadFromDefaults { var dictionary = [[CPUserDefaults standardUserDefaults] objectForKey:LibraryDefaultsKey]; if (!dictionary || ![dictionary isKindOfClass:[CPDictionary class]]) return NO; try { var people = [dictionary objectForKey:@"people"] || [], groups = [dictionary objectForKey:@"groups"] || [], folders = [dictionary objectForKey:@"smartFolders"] || []; for (var i = 0; i < people.length; i++) [self _insertPerson:[[Person alloc] initWithDictionary:people[i]]]; for (var i = 0; i < groups.length; i++) [[_groupsSection children] addObject:[[LibraryGroup alloc] initWithDictionary:groups[i]]]; for (var i = 0; i < folders.length; i++) [[_smartFoldersSection children] addObject:[[SmartFolder alloc] initWithDictionary:folders[i]]]; } catch (e) { CPLog.warn("SmartFoldersDemo: stored library is unreadable, using sample data. " + e); _people = []; _peopleByUID = {}; [[_groupsSection children] removeAllObjects]; [[_smartFoldersSection children] removeAllObjects]; return NO; } return [_people count] > 0; } // ---------------------------------------------------------------- Sample data - (void)_populateWithSampleData { // Deterministic Park-Miller generator so every fresh launch shows identical data. var seed = 20101125; function random() { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646; } function pick(anArray) { return anArray[Math.floor(random() * anArray.length)]; } // Intentionally includes diacritics to exercise [cd] predicate options. var firstNames = [@"Anna", @"Ben", @"Chloé", @"David", @"Elif", @"François", @"Greta", @"Hiro", @"Ingrid", @"José", @"Karl", @"Lena", @"Mateo", @"Nina", @"Oskar", @"Paula", @"Quentin", @"Renée", @"Søren", @"Tomás", @"Ute", @"Viktor", @"Wiebke", @"Xavier", @"Yuki", @"Zoë", @"Amélie", @"Björn", @"Clara", @"Dario"], lastNames = [@"Müller", @"Schmidt", @"Bauer", @"García", @"Dubois", @"Rossi", @"Nowak", @"Jensen", @"Tanaka", @"Smith", @"Johnson", @"Öztürk", @"Novák", @"Silva", @"Fischer", @"Weber", @"Lefèvre", @"Kowalski", @"Andersson", @"Brown", @"Costa", @"Hoffmann", @"Ivanova", @"Kim", @"Lambert", @"Martín", @"Nilsson", @"O'Brien", @"Peña", @"Wagner"]; function emailFor(aFirst, aLast) { var raw = (aFirst + "." + aLast).toLowerCase(); if (raw.normalize) raw = raw.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); return raw.replace(/ø/g, "o").replace(/[^a-z.]/g, "") + "@example.com"; } var count = 240; for (var i = 0; i < count; i++) { var uid = "p" + ("000" + i).slice(-4), person = [[Person alloc] initWithUID:uid], first = pick(firstNames), last = pick(lastNames); [person setFirstName:first]; [person setLastName:last]; [person setEmail:emailFor(first, last)]; [person setDepartment:pick(LibraryDepartments)]; [person setCity:pick(LibraryCities)]; [person setBirthDate:new Date(1955 + Math.floor(random() * 48), Math.floor(random() * 12), 1 + Math.floor(random() * 28))]; [person setSalary:38000 + Math.round(random() * 240) * 500]; [person setIsManager:random() < 0.12]; [self _insertPerson:person]; } // Groups (static membership) var someIDs = function(n) { var ids = []; while (ids.length < n) { var candidate = _people[Math.floor(random() * _people.length)]; if (ids.indexOf([candidate uid]) === -1) ids.push([candidate uid]); } return ids; }; [[_groupsSection children] addObject:[[LibraryGroup alloc] initWithName:@"Favorites" memberIDs:someIDs(6)]]; [[_groupsSection children] addObject:[[LibraryGroup alloc] initWithName:@"Hiking Club" memberIDs:someIDs(14)]]; [[_groupsSection children] addObject:[[LibraryGroup alloc] initWithName:@"Book Club" memberIDs:someIDs(9)]]; [[_groupsSection children] addObject:[[LibraryGroup alloc] initWithName:@"Drop People Here" memberIDs:[]]]; // Smart folders. Every predicate below is representable by the row templates // in SmartFolderEditorController, so CPPredicateEditor -setObjectValue: is // exercised on all of them. var folders = [ [@"Engineering over 40", [CPPredicate predicateWithFormat:@"department == %@ AND age > %@", @"Engineering", 40]], [@"Names starting with A", [CPPredicate predicateWithFormat:@"firstName BEGINSWITH[cd] %@", @"a"]], [@"Müller (diacritics test)",[CPPredicate predicateWithFormat:@"lastName CONTAINS[cd] %@", @"muller"]], [@"High earners in Berlin", [CPPredicate predicateWithFormat:@"salary >= %@ AND city ==[cd] %@", 120000, @"berlin"]], [@"Sales or Marketing", [CPPredicate predicateWithFormat:@"department == %@ OR department == %@", @"Sales", @"Marketing"]], [@"Not in Support", [CPPredicate predicateWithFormat:@"NOT (department == %@)", @"Support"]], [@"Nested compound", [CPPredicate predicateWithFormat:@"age < %@ AND (city ==[cd] %@ OR city ==[cd] %@)", 35, @"tokyo", @"lisbon"]] ]; for (var i = 0; i < folders.length; i++) [[_smartFoldersSection children] addObject:[[SmartFolder alloc] initWithName:folders[i][0] predicate:folders[i][1]]]; } @end