diff --git a/README.md b/README.md index 88a2d00..9fbb371 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ ToDo - [ ] Choose status bar icon? - [ ] Tick mark feed items based on prefs - [ ] Open a few links (# editable) - - [ ] Performance: Update menu partially + - [x] Performance: Update menu partially - [x] Start on login - [x] Make it system default application - [ ] Display license info (e.g., RSXML) @@ -45,10 +45,11 @@ ToDo - [ ] Status menu - - [ ] Update menu header after mark (un)read + - [x] Update menu header after mark (un)read - [ ] Pause updates functionality - [x] Update all feeds functionality - - [ ] Hold only relevant information in memory + - [x] Hold only relevant information in memory + - [ ] Icon for paused / no internet state - [ ] Edit feed @@ -68,7 +69,7 @@ ToDo - [ ] Translate text to different languages - [x] Automatically update feeds with chosen interval - [x] Reuse ETag and Modification date - - ~~[ ] Append only new items, keep sorting~~ + - [x] Append only new items, keep sorting - [x] Delete old ones eventually - [x] Pause on internet connection lost - [ ] Download with ephemeral url session? @@ -85,7 +86,7 @@ ToDo - [ ] Notification Center - [ ] Sleep timer. (e.g., disable updates during working hours) - [ ] Pure image feed? (show images directly in menu) - - [ ] Infinite storage. (load more button) + - ~~[ ] Infinite storage. (load more button)~~ - [ ] Automatically open feed items? - [ ] Per feed launch application (e.g., for podcasts) - [ ] Per group setting to exclude unread count from menu bar diff --git a/baRSS/Categories/Feed+Ext.h b/baRSS/Categories/Feed+Ext.h index 2fbd6e0..92d9fde 100644 --- a/baRSS/Categories/Feed+Ext.h +++ b/baRSS/Categories/Feed+Ext.h @@ -25,8 +25,9 @@ @class RSParsedFeed; @interface Feed (Ext) -+ (Feed*)feedFromRSS:(RSParsedFeed*)obj inContext:(NSManagedObjectContext*)context alreadyRead:(NSArray*)urls unread:(int*)unreadCount; -- (NSArray*)alreadyReadURLs; -- (void)markAllItemsRead; -- (void)markAllItemsUnread; +- (void)updateWithRSS:(RSParsedFeed*)obj; +- (NSArray*)sortedArticles; + +- (int)markAllItemsRead; +- (int)markAllItemsUnread; @end diff --git a/baRSS/Categories/Feed+Ext.m b/baRSS/Categories/Feed+Ext.m index 588e3b5..ad68d6b 100644 --- a/baRSS/Categories/Feed+Ext.m +++ b/baRSS/Categories/Feed+Ext.m @@ -27,8 +27,50 @@ @implementation Feed (Ext) -+ (FeedItem*)createFeedItemFrom:(RSParsedArticle*)entry inContext:(NSManagedObjectContext*)context { - FeedItem *b = [[FeedItem alloc] initWithEntity:FeedItem.entity insertIntoManagedObjectContext:context]; +/** + Replace feed title, subtitle and link (if changed). Also adds new articles and removes old ones. + */ +- (void)updateWithRSS:(RSParsedFeed*)obj { + if (![self.title isEqualToString:obj.title]) self.title = obj.title; + if (![self.subtitle isEqualToString:obj.subtitle]) self.subtitle = obj.subtitle; + if (![self.link isEqualToString:obj.link]) self.link = obj.link; + + NSMutableSet *urls = [[self.items valueForKeyPath:@"link"] mutableCopy]; + if ([self addMissingArticles:obj updateLinks:urls]) // will remove links in 'urls' that should be kept + [self deleteArticlesWithLink:urls]; // remove old, outdated articles +} + +/** + Append new articles and increment their sortIndex. Update article counter and unread counter on the way. + + @param urls Input will be used to identify new articles. Output will contain URLs that aren't present in the feed anymore. + @return @c YES if new items were added, @c NO otherwise. + */ +- (BOOL)addMissingArticles:(RSParsedFeed*)obj updateLinks:(NSMutableSet*)urls { + int latestID = [[self.items valueForKeyPath:@"@max.sortIndex"] intValue]; + __block int newOnes = 0; + [obj.articles enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(RSParsedArticle * _Nonnull article, BOOL * _Nonnull stop) { + // reverse enumeration ensures correct article order + if ([urls containsObject:article.link]) { + [urls removeObject:article.link]; + } else { + newOnes += 1; + [self insertArticle:article atIndex:latestID + newOnes]; + } + }]; + if (newOnes == 0) return NO; + self.articleCount += newOnes; + self.unreadCount += newOnes; // new articles are by definition unread + return YES; +} + +/** + Create article based on input and insert into core data storage. + */ +- (void)insertArticle:(RSParsedArticle*)entry atIndex:(int)idx { + FeedItem *b = [[FeedItem alloc] initWithEntity:FeedItem.entity insertIntoManagedObjectContext:self.managedObjectContext]; + b.sortIndex = (int32_t)idx; + b.unread = YES; b.guid = entry.guid; b.title = entry.title; b.abstract = entry.abstract; @@ -36,54 +78,72 @@ b.author = entry.author; b.link = entry.link; b.published = entry.datePublished; - return b; + [self addItemsObject:b]; } -+ (Feed*)feedFromRSS:(RSParsedFeed*)obj inContext:(NSManagedObjectContext*)context alreadyRead:(NSArray*)urls unread:(int*)unreadCount { - Feed *a = [[Feed alloc] initWithEntity:Feed.entity insertIntoManagedObjectContext:context]; - a.title = obj.title; - a.subtitle = obj.subtitle; - a.link = obj.link; - for (RSParsedArticle *article in obj.articles) { - FeedItem *b = [self createFeedItemFrom:article inContext:context]; - if ([urls containsObject:b.link]) { - b.unread = NO; - } else { - *unreadCount += 1; - } - [a addItemsObject:b]; - } - return a; -} - -- (NSArray*)alreadyReadURLs { - if (!self.items || self.items.count == 0) return nil; - NSMutableArray *mArr = [NSMutableArray arrayWithCapacity:self.items.count]; - for (FeedItem *f in self.items) { - if (!f.unread) { - [mArr addObject:f.link]; +/** + Delete all items where @c link matches one of the URLs in the @c NSSet. + */ +- (void)deleteArticlesWithLink:(NSMutableSet*)urls { + if (!urls || urls.count == 0) + return; + + self.articleCount -= (int32_t)urls.count; + for (FeedItem *item in self.items) { + if ([urls containsObject:item.link]) { + [urls removeObject:item.link]; + if (item.unread) + self.unreadCount -= 1; + // TODO: keep unread articles? + [item.managedObjectContext deleteObject:item]; + if (urls.count == 0) + break; } } - return mArr; } -- (void)markAllItemsRead { - [self markAllArticlesRead:YES]; +/** + @return Articles sorted by attribute @c sortIndex with descending order (newest items first). + */ +- (NSArray*)sortedArticles { + if (self.items.count == 0) + return nil; + return [self.items sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:NO]]]; } -- (void)markAllItemsUnread { - [self markAllArticlesRead:NO]; +/** + For all articles set @c unread @c = @c NO + + @return Change in unread count. (0 or negative number) + */ +- (int)markAllItemsRead { + return [self markAllArticlesRead:YES]; } -- (void)markAllArticlesRead:(BOOL)readFlag { - int count = 0; +/** + For all articles set @c unread @c = @c YES + + @return Change in unread count. (0 or positive number) + */ +- (int)markAllItemsUnread { + return [self markAllArticlesRead:NO]; +} + +/** + Mark all articles read or unread and update @c unreadCount + + @param readFlag @c YES: mark items read; @c NO: mark items unread + */ +- (int)markAllArticlesRead:(BOOL)readFlag { for (FeedItem *i in self.items) { - if (i.unread == readFlag) { + if (i.unread == readFlag) i.unread = !readFlag; - ++count; - } } - [self.config markUnread:(readFlag ? -count : +count) ancestorsOnly:NO]; + int32_t oldCount = self.unreadCount; + int32_t newCount = (readFlag ? 0 : self.articleCount); + if (self.unreadCount != newCount) + self.unreadCount = newCount; + return newCount - oldCount; } @end diff --git a/baRSS/Categories/FeedConfig+Ext.h b/baRSS/Categories/FeedConfig+Ext.h index 17f8f53..8a482d9 100644 --- a/baRSS/Categories/FeedConfig+Ext.h +++ b/baRSS/Categories/FeedConfig+Ext.h @@ -33,16 +33,15 @@ typedef enum int16_t { } FeedConfigType; @property (getter=typ, setter=setTyp:) FeedConfigType typ; - -- (NSArray*)sortedChildren; -- (NSIndexPath*)indexPath; -- (void)markUnread:(int)count ancestorsOnly:(BOOL)flag; -- (void)calculateAndSetScheduled; -- (BOOL)iterateSorted:(BOOL)ordered overDescendantFeeds:(void(^)(Feed*,BOOL*))block; - -- (void)setEtag:(NSString*)etag modified:(NSString*)modified; +// Handle children and parents +- (NSString*)indexPathString; +- (NSMutableArray*)allParents; +- (BOOL)iterateSorted:(BOOL)ordered overDescendantFeeds:(void(^)(Feed *feed, BOOL* cancel))block; +// Update feed and meta - (void)updateRSSFeed:(RSParsedFeed*)obj; - +- (void)setEtag:(NSString*)etag modified:(NSString*)modified; +- (void)calculateAndSetScheduled; +// Printing - (NSString*)readableRefreshString; - (NSString*)readableDescription; @end diff --git a/baRSS/Categories/FeedConfig+Ext.m b/baRSS/Categories/FeedConfig+Ext.m index 41fb005..ef432eb 100644 --- a/baRSS/Categories/FeedConfig+Ext.m +++ b/baRSS/Categories/FeedConfig+Ext.m @@ -31,76 +31,40 @@ /// Enum type setter see @c FeedConfigType - (void)setTyp:(FeedConfigType)typ { self.type = typ; } -/** - Sorted children array based on sort order provided in feed settings. - @return Sorted array of @c FeedConfig items. - */ +#pragma mark - Handle Children And Parents - + + +/// @return IndexPath as semicolon separated string for sorted children starting with root index. +- (NSString*)indexPathString { + if (self.parent == nil) + return [NSString stringWithFormat:@"%d", self.sortIndex]; + return [[self.parent indexPathString] stringByAppendingFormat:@".%d", self.sortIndex]; +} + +/// @return Children sorted by attribute @c sortIndex (same order as in preferences). - (NSArray*)sortedChildren { if (self.children.count == 0) return nil; return [self.children sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]]]; } -/// IndexPath for sorted children starting with root index. -- (NSIndexPath*)indexPath { +/// @return @c NSArray of all ancestors: First object is root. Last object is the @c FeedConfig that executed the command. +- (NSMutableArray*)allParents { if (self.parent == nil) - return [NSIndexPath indexPathWithIndex:(NSUInteger)self.sortIndex]; - return [[self.parent indexPath] indexPathByAddingIndex:(NSUInteger)self.sortIndex]; + return [NSMutableArray arrayWithObject:self]; + NSMutableArray *arr = [self.parent allParents]; + [arr addObject:self]; + return arr; } /** - Change unread counter for all parents recursively. Result will never be negative. + Iterate over all descenden feeds. - @param count If negative, mark items read. + @param ordered If @c YES items are executed in the same order they are listed in the menu. Pass @n NO for a speed-up. + @param block Set @c cancel to @c YES to stop execution of further descendants. + @return @c NO if execution was stopped with @c cancel @c = @c YES in @c block. */ -- (void)markUnread:(int)count ancestorsOnly:(BOOL)flag { - FeedConfig *par = (flag ? self.parent : self); - while (par) { - [self.managedObjectContext refreshObject:par mergeChanges:YES]; - par.unreadCount += count; - NSAssert(par.unreadCount >= 0, @"ERROR ancestorsMarkUnread: Count should never be negative."); - par = par.parent; - } - [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged - object:[NSNumber numberWithInt:count]]; -} - -/// @return Time interval respecting the selected unit. E.g., returns @c 180 for @c '3m' -- (NSTimeInterval)timeInterval { - static const int unit[] = {1, 60, 3600, 86400, 604800}; // smhdw - return self.refreshNum * unit[self.refreshUnit % 5]; -} - -/// Calculate date from @c refreshNum and @c refreshUnit and set as next scheduled feed update. -- (void)calculateAndSetScheduled { - self.scheduled = [[NSDate date] dateByAddingTimeInterval:[self timeInterval]]; -} - -/// Update FeedMeta or create new one if needed. -- (void)setEtag:(NSString*)etag modified:(NSString*)modified { - // TODO: move to separate function and add icon download - if (!self.meta) { - self.meta = [[FeedMeta alloc] initWithEntity:FeedMeta.entity insertIntoManagedObjectContext:self.managedObjectContext]; - } - self.meta.httpEtag = etag; - self.meta.httpModified = modified; -} - -/// Delete any existing feed object and parse new one. Read state will be copied. -- (void)updateRSSFeed:(RSParsedFeed*)obj { - NSArray *readURLs = [self.feed alreadyReadURLs]; - int unreadBefore = self.unreadCount; - int unreadAfter = 0; - if (self.feed) - [self.managedObjectContext deleteObject:(NSManagedObject*)self.feed]; - if (obj) { - // TODO: update and dont re-create each time - self.feed = [Feed feedFromRSS:obj inContext:self.managedObjectContext alreadyRead:readURLs unread:&unreadAfter]; - } - [self markUnread:(unreadAfter - unreadBefore) ancestorsOnly:NO]; -} - - (BOOL)iterateSorted:(BOOL)ordered overDescendantFeeds:(void(^)(Feed*,BOOL*))block { if (self.feed) { BOOL stopEarly = NO; @@ -115,8 +79,46 @@ return YES; } + +#pragma mark - Update Feed And Meta - + + +/// Delete any existing feed object and parse new one. Read state will be copied. +- (void)updateRSSFeed:(RSParsedFeed*)obj { + if (!self.feed) { + self.feed = [[Feed alloc] initWithEntity:Feed.entity insertIntoManagedObjectContext:self.managedObjectContext]; + self.feed.indexPath = [self indexPathString]; + } + int32_t unreadBefore = self.feed.unreadCount; + [self.feed updateWithRSS:obj]; + NSNumber *cDiff = [NSNumber numberWithInteger:self.feed.unreadCount - unreadBefore]; + [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged object:cDiff]; +} + +/// Update FeedMeta or create new one if needed. +- (void)setEtag:(NSString*)etag modified:(NSString*)modified { + if (!self.meta) { + self.meta = [[FeedMeta alloc] initWithEntity:FeedMeta.entity insertIntoManagedObjectContext:self.managedObjectContext]; + } + if (![self.meta.httpEtag isEqualToString:etag]) self.meta.httpEtag = etag; + if (![self.meta.httpModified isEqualToString:modified]) self.meta.httpModified = modified; +} + +/// Calculate date from @c refreshNum and @c refreshUnit and set as next scheduled feed update. +- (void)calculateAndSetScheduled { + self.scheduled = [[NSDate date] dateByAddingTimeInterval:[self timeInterval]]; +} + +/// @return Time interval respecting the selected unit. E.g., returns @c 180 for @c '3m' +- (NSTimeInterval)timeInterval { + static const int unit[] = {1, 60, 3600, 86400, 604800}; // smhdw + return self.refreshNum * unit[self.refreshUnit % 5]; +} + + #pragma mark - Printing - + /// @return Formatted string for update interval ( e.g., @c 30m or @c 12h ) - (NSString*)readableRefreshString { return [NSString stringWithFormat:@"%d%c", self.refreshNum, [@"smhdw" characterAtIndex:self.refreshUnit % 5]]; diff --git a/baRSS/Constants.h b/baRSS/Constants.h index d56429e..363a1be 100644 --- a/baRSS/Constants.h +++ b/baRSS/Constants.h @@ -26,5 +26,10 @@ static NSString *kNotificationFeedUpdated = @"baRSS-notification-feed-updated"; static NSString *kNotificationNetworkStatusChanged = @"baRSS-notification-network-status-changed"; static NSString *kNotificationTotalUnreadCountChanged = @"baRSS-notification-total-unread-count-changed"; +static NSString *kNotificationTotalUnreadCountReset = @"baRSS-notification-total-unread-count-reset"; + +extern uint64_t dispatch_benchmark(size_t count, void (^block)(void)); +//void benchmark(char *desc, dispatch_block_t b){printf("%s: %llu ns\n", desc, dispatch_benchmark(1, b));} +#define benchmark(desc,block) printf(desc": %llu ns\n", dispatch_benchmark(1, block)); #endif /* Constants_h */ diff --git a/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents b/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents index 9e64484..a2dbab6 100644 --- a/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents +++ b/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents @@ -1,11 +1,14 @@ + + + - + @@ -15,7 +18,6 @@ - @@ -29,6 +31,7 @@ + @@ -40,9 +43,9 @@ - - - + + + \ No newline at end of file diff --git a/baRSS/FeedDownload.m b/baRSS/FeedDownload.m index ed7823f..2d1f529 100644 --- a/baRSS/FeedDownload.m +++ b/baRSS/FeedDownload.m @@ -70,7 +70,7 @@ static BOOL _isReachable = NO; + (void)scheduleNextUpdate:(BOOL)forceUpdate { static NSTimer *_updateTimer; - @synchronized (_updateTimer) { + @synchronized (_updateTimer) { // TODO: dig into analyzer warning if (_updateTimer) { [_updateTimer invalidate]; _updateTimer = nil; @@ -112,7 +112,7 @@ static BOOL _isReachable = NO; } dispatch_group_notify(group, dispatch_get_main_queue(), ^{ [StoreCoordinator saveContext:childContext andParent:YES]; - [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:nil]; + [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:[list valueForKeyPath:@"objectID"]]; [childContext reset]; childContext = nil; [self scheduleNextUpdate:NO]; // after forced update, continue regular cycle diff --git a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m index accb5fc..6f9dac0 100644 --- a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m +++ b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m @@ -111,6 +111,7 @@ if (self.shouldDeletePrevArticles) { [item updateRSSFeed:self.feedResult]; [item setEtag:self.httpEtag modified:self.httpDate]; + // TODO: add icon download } if ([item.managedObjectContext hasChanges]) { self.objectIsModified = YES; diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeeds.m b/baRSS/Preferences/Feeds Tab/SettingsFeeds.m index 5c04781..fa8ea33 100644 --- a/baRSS/Preferences/Feeds Tab/SettingsFeeds.m +++ b/baRSS/Preferences/Feeds Tab/SettingsFeeds.m @@ -26,6 +26,7 @@ #import "ModalFeedEdit.h" #import "DrawImage.h" #import "StoreCoordinator.h" +#import "Constants.h" @interface SettingsFeeds () @property (weak) IBOutlet NSOutlineView *outlineView; @@ -77,11 +78,14 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; - (IBAction)remove:(id)sender { [self.undoManager beginUndoGrouping]; - for (NSIndexPath *path in self.dataStore.selectionIndexPaths) - [self incrementIndicesBy:-1 forSubsequentNodes:path]; + NSArray *parentNodes = [self.dataStore.selectedNodes valueForKeyPath:@"parentNode"]; [self.dataStore remove:sender]; + for (NSTreeNode *parent in parentNodes) { + [self restoreOrderingAndIndexPathStr:parent]; + } [self.undoManager endUndoGrouping]; [self saveChanges]; + [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil]; } - (IBAction)doubleClickOutlineView:(NSOutlineView*)sender { @@ -126,48 +130,58 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; }]; } +/// Called after an item was modified. May be called twice if download was still in progress. - (void)modalDidUpdateFeedConfig:(FeedConfig*)config { - [self saveChanges]; // TODO: adjust total count + [self saveChanges]; + [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil]; } + +#pragma mark - Helper - + +/// Insert @c FeedConfig item either after current selection or inside selected folder (if expanded) - (FeedConfig*)insertSortedItemAtSelection { - NSIndexPath *selectedIndex = [self.dataStore selectionIndexPath]; - if (selectedIndex == NULL) - selectedIndex = [NSIndexPath new]; - NSIndexPath *insertIndex = selectedIndex; - - FeedConfig *selected = [[[self.dataStore arrangedObjects] descendantNodeAtIndexPath:selectedIndex] representedObject]; - NSUInteger lastIndex = (selected ? selected.children.count : self.dataStore.arrangedObjects.childNodes.count); - BOOL groupSelected = (selected.typ == GROUP); - - if (!groupSelected) { - lastIndex = (NSUInteger)selected.sortIndex + 1; // insert after selection - insertIndex = [insertIndex indexPathByRemovingLastIndex]; - [self incrementIndicesBy:+1 forSubsequentNodes:selectedIndex]; - --selected.sortIndex; // insert after selection - } - FeedConfig *newItem = [[FeedConfig alloc] initWithEntity:FeedConfig.entity insertIntoManagedObjectContext:self.dataStore.managedObjectContext]; - [self.dataStore insertObject:newItem atArrangedObjectIndexPath:[insertIndex indexPathByAddingIndex:lastIndex]]; - // First insert, then parent, else troubles - newItem.sortIndex = (int32_t)lastIndex; - newItem.parent = (groupSelected ? selected : selected.parent); + NSTreeNode *selection = [[self.dataStore selectedNodes] firstObject]; + NSIndexPath *pth = nil; + + if (!selection) { // append to root + pth = [NSIndexPath indexPathWithIndex:[self.dataStore arrangedObjects].childNodes.count]; // or 0 to append at front + } else if ([self.outlineView isItemExpanded:selection]) { // append to group (if open) + pth = [selection.indexPath indexPathByAddingIndex:0]; // or 'selection.childNodes.count' to append at end + } else { // append before / after selected item + pth = selection.indexPath; + // remove the two lines below to insert infront of selection (instead of after selection) + NSUInteger lastIdx = [pth indexAtPosition:pth.length - 1]; + pth = [[pth indexPathByRemovingLastIndex] indexPathByAddingIndex:lastIdx + 1]; + } + [self.dataStore insertObject:newItem atArrangedObjectIndexPath:pth]; + + if (pth.length > 2) { // some subfolder; not root folder (has parent!) + NSTreeNode *parentNode = [[self.dataStore arrangedObjects] descendantNodeAtIndexPath:pth].parentNode; + newItem.parent = parentNode.representedObject; + [self restoreOrderingAndIndexPathStr:parentNode]; + } else { + [self restoreOrderingAndIndexPathStr:[self.dataStore arrangedObjects]]; // .parent = nil + } return newItem; } - -#pragma mark - Import & Export of Data - - -- (void)incrementIndicesBy:(int)val forSubsequentNodes:(NSIndexPath*)path { - NSIndexPath *parentPath = [path indexPathByRemovingLastIndex]; - NSTreeNode *root = [self.dataStore arrangedObjects]; - if (parentPath.length > 0) - root = [root descendantNodeAtIndexPath:parentPath]; - - for (NSUInteger i = [path indexAtPosition:path.length - 1]; i < root.childNodes.count; i++) { - FeedConfig *conf = [root.childNodes[i] representedObject]; - conf.sortIndex += val; +/// Loop over all descendants and update @c sortIndex @c (FeedConfig) as well as all @c indexPath @c (Feed) +- (void)restoreOrderingAndIndexPathStr:(NSTreeNode*)parent { + NSArray *children = parent.childNodes; + for (NSUInteger i = 0; i < children.count; i++) { + NSTreeNode *n = [children objectAtIndex:i]; + FeedConfig *fc = n.representedObject; + // Re-calculate sort index for all affected parents + if (fc.sortIndex != (int32_t)i) + fc.sortIndex = (int32_t)i; + // Re-calculate index path for all contained feed items + [fc iterateSorted:NO overDescendantFeeds:^(Feed *feed, BOOL *cancel) { + NSString *pthStr = [feed.config indexPathString]; + if (![feed.indexPath isEqualToString:pthStr]) + feed.indexPath = pthStr; + }]; } } @@ -196,49 +210,20 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; } - (BOOL)outlineView:(NSOutlineView *)outlineView acceptDrop:(id )info item:(id)item childIndex:(NSInteger)index { - NSArray *dstChildren = [item childNodes]; - if (!item || !dstChildren) - dstChildren = [self.dataStore arrangedObjects].childNodes; + NSTreeNode *destParent = (item != nil ? item : [self.dataStore arrangedObjects]); + NSUInteger idx = (NSUInteger)index; + if (index == -1) // drag items on folder or root drop + idx = destParent.childNodes.count; + NSIndexPath *dest = [destParent indexPath]; - BOOL isFolderDrag = (index == -1); - NSUInteger insertIndex = (isFolderDrag ? dstChildren.count : (NSUInteger)index); - // index where the items will be moved to, but not final since items above can vanish - NSIndexPath *dest = [item indexPath]; - if (!dest) dest = [NSIndexPath indexPathWithIndex:insertIndex]; - else dest = [dest indexPathByAddingIndex:insertIndex]; + NSArray *previousParents = [self.currentlyDraggedNodes valueForKeyPath:@"parentNode"]; + [self.dataStore moveNodes:self.currentlyDraggedNodes toIndexPath:[dest indexPathByAddingIndex:idx]]; - // decrement index for every item that is dragged from the same location (above the destination) - NSUInteger updateIndex = insertIndex; - for (NSTreeNode *node in self.currentlyDraggedNodes) { - NSIndexPath *nodesPath = [node indexPath]; - if ([[nodesPath indexPathByRemovingLastIndex] isEqualTo:[dest indexPathByRemovingLastIndex]] && - insertIndex > [nodesPath indexAtPosition:nodesPath.length - 1]) - { - --updateIndex; - } - } - for (NSUInteger i = self.currentlyDraggedNodes.count; i > 0; i--) { // sorted that way to handle children first - FeedConfig *fc = [self.currentlyDraggedNodes[i - 1] representedObject]; - [fc.managedObjectContext refreshObject:fc mergeChanges:YES]; // make sure unreadCount is correct - [fc markUnread:-fc.unreadCount ancestorsOnly:YES]; + for (NSTreeNode *node in previousParents) { + [self restoreOrderingAndIndexPathStr:node]; } + [self restoreOrderingAndIndexPathStr:destParent]; - // decrement sort indices at source - for (NSTreeNode *node in self.currentlyDraggedNodes) - [self incrementIndicesBy:-1 forSubsequentNodes:[node indexPath]]; - // increment sort indices at destination - if (!isFolderDrag) - [self incrementIndicesBy:(int)self.currentlyDraggedNodes.count forSubsequentNodes:dest]; - - // move items - [self.dataStore moveNodes:self.currentlyDraggedNodes toIndexPath:dest]; - - // set sort indices for dragged items - for (NSUInteger i = 0; i < self.currentlyDraggedNodes.count; i++) { - FeedConfig *fc = [self.currentlyDraggedNodes[i] representedObject]; - fc.sortIndex = (int32_t)(updateIndex + i); - [fc markUnread:fc.unreadCount ancestorsOnly:YES]; - } return YES; } @@ -317,15 +302,15 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; - (void)undo:(id)sender { [self.undoManager undo]; - [StoreCoordinator restoreUnreadCount]; [self saveChanges]; + [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil]; [self.dataStore rearrangeObjects]; // update ordering } - (void)redo:(id)sender { [self.undoManager redo]; - [StoreCoordinator restoreUnreadCount]; [self saveChanges]; + [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil]; [self.dataStore rearrangeObjects]; // update ordering } diff --git a/baRSS/Preferences/General Tab/SettingsGeneral.m b/baRSS/Preferences/General Tab/SettingsGeneral.m index 6bdec64..b906f61 100644 --- a/baRSS/Preferences/General Tab/SettingsGeneral.m +++ b/baRSS/Preferences/General Tab/SettingsGeneral.m @@ -60,7 +60,7 @@ - (IBAction)fixCache:(NSButton *)sender { [StoreCoordinator deleteUnreferencedFeeds]; - [StoreCoordinator restoreUnreadCount]; + [StoreCoordinator restoreFeedCountsAndIndexPaths]; } - (IBAction)changeMenuBarIconSetting:(NSButton*)sender { diff --git a/baRSS/Status Bar Menu/BarMenu.h b/baRSS/Status Bar Menu/BarMenu.h index c0afecb..1db2afb 100644 --- a/baRSS/Status Bar Menu/BarMenu.h +++ b/baRSS/Status Bar Menu/BarMenu.h @@ -24,4 +24,5 @@ @interface BarMenu : NSObject - (void)updateBarIcon; +- (void)reloadUnreadCountAndUpdateBarIcon; @end diff --git a/baRSS/Status Bar Menu/BarMenu.m b/baRSS/Status Bar Menu/BarMenu.m index d944988..e761a06 100644 --- a/baRSS/Status Bar Menu/BarMenu.m +++ b/baRSS/Status Bar Menu/BarMenu.m @@ -27,7 +27,6 @@ #import "Preferences.h" #import "UserPrefs.h" #import "NSMenu+Ext.h" -#import "NSMenuItem+Ext.h" #import "Feed+Ext.h" #import "Constants.h" @@ -35,9 +34,9 @@ @interface BarMenu() @property (strong) NSStatusItem *barItem; @property (strong) Preferences *prefWindow; -@property (assign) int unreadCountTotal; -@property (strong) NSArray *allFeeds; -@property (strong) NSArray *currentOpenMenu; +@property (assign, atomic) NSInteger unreadCountTotal; +@property (weak) NSMenu *currentOpenMenu; +@property (strong) NSArray *objectIDsForMenu; @property (strong) NSManagedObjectContext *readContext; @end @@ -53,15 +52,13 @@ // Unread counter self.unreadCountTotal = 0; [self updateBarIcon]; - dispatch_async(dispatch_get_main_queue(), ^{ - self.unreadCountTotal = [StoreCoordinator totalNumberOfUnreadFeeds]; - [self updateBarIcon]; - }); + [self reloadUnreadCountAndUpdateBarIcon]; // Register for notifications [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedUpdated:) name:kNotificationFeedUpdated object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(networkChanged:) name:kNotificationNetworkStatusChanged object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(unreadCountChanged:) name:kNotificationTotalUnreadCountChanged object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(reloadUnreadCountAndUpdateBarIcon) name:kNotificationTotalUnreadCountReset object:nil]; [FeedDownload registerNetworkChangeNotification]; [FeedDownload performSelectorInBackground:@selector(scheduleNextUpdate:) withObject:[NSNumber numberWithBool:NO]]; return self; @@ -72,15 +69,23 @@ [[NSNotificationCenter defaultCenter] removeObserver:self]; } -/** - Update menu bar icon and text according to unread count and user preferences. - */ +#pragma mark - Update Menu Bar Icon - + +/// Regardless of current unread count, perform new core data fetch on total unread count and update icon. +- (void)reloadUnreadCountAndUpdateBarIcon { + dispatch_async(dispatch_get_main_queue(), ^{ + self.unreadCountTotal = [StoreCoordinator unreadCountForIndexPathString:nil]; + [self updateBarIcon]; + }); +} + +/// Update menu bar icon and text according to unread count and user preferences. - (void)updateBarIcon { // TODO: Option: icon choice // TODO: Show paused icon if no internet connection dispatch_async(dispatch_get_main_queue(), ^{ if (self.unreadCountTotal > 0 && [UserPrefs defaultYES:@"globalUnreadCount"]) { - self.barItem.title = [NSString stringWithFormat:@"%d", self.unreadCountTotal]; + self.barItem.title = [NSString stringWithFormat:@"%ld", self.unreadCountTotal]; } else { self.barItem.title = @""; } @@ -119,42 +124,62 @@ [self updateBarIcon]; } -/** - Callback method fired when feeds have been updated in the background. - */ +/// Callback method fired when feeds have been updated in the background. - (void)feedUpdated:(NSNotification*)notify { if (self.barItem.menu.numberOfItems > 0) { // update items only if menu is already open (e.g., during background update) - [self.readContext refreshAllObjects]; // because self.allFeeds is the same context - [self recursiveUpdateMenu:self.barItem.menu withFeed:nil]; + NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; + for (NSManagedObjectID *oid in notify.object) { + FeedConfig *fc = [moc objectWithID:oid]; + NSMenu *menu = [self fixUnreadCountForSubmenus:fc]; + if (!menu || menu.numberOfItems > 0) + [self rebuiltFeedItems:fc.feed inMenu:menu]; // deepest menu level, feed items + } + [self.barItem.menu autoEnableMenuHeader:(self.unreadCountTotal > 0)]; // once per multi-feed update + [moc reset]; } } /** - Called recursively for all @c FeedConfig children. - If the projected submenu in @c menu does not exist, all subsequent children are skipped in @c FeedConfig. - The title and unread count is updated for all menu items. @c FeedItem menus are completely re-generated. + Go through all parent menus and reset the menu title and unread count - @param config If @c nil the root object (@c self.allFeeds) is used. + @param config Should contain a @c Feed object in @c config.feed. + @return @c NSMenu containing @c FeedItem. Will be @c nil if user hasn't open the menu yet. */ -- (void)recursiveUpdateMenu:(NSMenu*)menu withFeed:(FeedConfig*)config { - if (config.feed.items.count > 0) { // deepest menu level, feed items +- (nullable NSMenu*)fixUnreadCountForSubmenus:(FeedConfig*)config { + NSMenu *menu = self.barItem.menu; + for (FeedConfig *conf in [config allParents]) { + NSInteger offset = [menu feedConfigOffset]; + NSMenuItem *item = [menu itemAtIndex:offset + conf.sortIndex]; + NSInteger unread = [item setTitleAndUnreadCount:conf]; + menu = item.submenu; + if (!menu || menu.numberOfItems == 0) + return nil; + if (unread == 0) // if != 0 then 'setTitleAndUnreadCount' was successful (UserPrefs visible) + unread = [menu coreDataUnreadCount]; + [menu autoEnableMenuHeader:(unread > 0)]; // of submenu (including: feed items menu) + } + return menu; +} + +/** + Remove all @c NSMenuItem in menu and generate new ones. items from @c feed.items. + + @param feed Corresponding @c Feed to @c NSMenu. + @param menu Deepest menu level which contains only feed items. + */ +- (void)rebuiltFeedItems:(Feed*)feed inMenu:(NSMenu*)menu { + if (self.currentOpenMenu != menu) { + // if the menu isn't open, re-create it dynamically instead + menu.itemArray.firstObject.parentItem.submenu = [menu cleanInstanceCopy]; + } else { [menu removeAllItems]; - [self insertDefaultHeaderForAllMenus:menu scope:ScopeFeed hasUnread:(config.unreadCount > 0)]; - for (FeedItem *fi in config.feed.items) { + [self insertDefaultHeaderForAllMenus:menu hasUnread:(feed.unreadCount > 0)]; + for (FeedItem *fi in [feed sortedArticles]) { NSMenuItem *mi = [menu addItemWithTitle:@"" action:@selector(openFeedURL:) keyEquivalent:@""]; mi.target = self; [mi setFeedItem:fi]; } - } else { - BOOL hasUnread = (config ? config.unreadCount > 0 : self.unreadCountTotal > 0); - NSInteger offset = [menu getFeedConfigOffsetAndUpdateUnread:hasUnread]; - for (FeedConfig *child in (config ? config.children : self.allFeeds)) { - NSMenuItem *item = [menu itemAtIndex:offset + child.sortIndex]; - [item setTitleAndUnreadCount:child]; - if (item.submenu.numberOfItems > 0) - [self recursiveUpdateMenu:[item submenu] withFeed:child]; - } } } @@ -162,102 +187,70 @@ #pragma mark - Menu Delegate & Menu Generation - -// Get rid of everything that is not needed when the system bar menu isnt open. -- (void)menuDidClose:(NSMenu*)menu { - if ([menu isMainMenu]) { - self.allFeeds = nil; - [self.readContext reset]; - self.readContext = nil; - self.barItem.menu = [NSMenu menuWithDelegate:self]; - } +/// @c currentOpenMenu is needed when a background update occurs. In case a feed items menu is open. +- (void)menuWillOpen:(NSMenu *)menu { + self.currentOpenMenu = menu; } -// If main menu load inital set of items, then find item based on index path. -- (NSInteger)numberOfItemsInMenu:(NSMenu*)menu { - if ([menu isMainMenu]) { - [self.readContext reset]; // will be ignored if nil - self.readContext = [StoreCoordinator createChildContext]; - self.allFeeds = [StoreCoordinator sortedFeedConfigItemsInContext:self.readContext]; - self.currentOpenMenu = [self.allFeeds valueForKeyPath:@"objectID"]; - } else { - FeedConfig *conf = [self configAtIndexPathStr:menu.title]; - [self.readContext refreshObject:conf mergeChanges:YES]; - self.currentOpenMenu = [(conf.typ == FEED ? conf.feed.items : [conf sortedChildren]) valueForKeyPath:@"objectID"]; - } - return (NSInteger)[self.currentOpenMenu count]; +/// Get rid of everything that is not needed when the system bar menu is closed. +- (void)menuDidClose:(NSMenu*)menu { + self.currentOpenMenu = nil; + if ([menu isMainMenu]) + self.barItem.menu = [NSMenu menuWithDelegate:self]; } /** - Find @c FeedConfig item in array @c self.allFeeds that is already loaded. - - @param indexString Path as string that is stored in @c NSMenu title + @note Delegate method not used. Here to prevent weird @c NSMenu behavior. + Otherwise, Cmd-Q (Quit) and Cmd-, (Preferences) will traverse all submenus. + Try yourself with @c NSLog() in @c numberOfItemsInMenu: and @c menuDidClose: */ -- (FeedConfig*)configAtIndexPathStr:(NSString*)indexString { - NSArray *parts = [indexString componentsSeparatedByString:@"."]; - NSInteger firstIndex = [[parts objectAtIndex:1] integerValue]; - FeedConfig *changing = [self.allFeeds objectAtIndex:(NSUInteger)firstIndex]; - for (NSUInteger i = 2; i < parts.count; i++) { - NSInteger childIndex = [[parts objectAtIndex:i] integerValue]; - BOOL err = YES; - for (FeedConfig *c in changing.children) { - if (c.sortIndex == childIndex) { - err = NO; - changing = c; - break; // Exit early. Should be faster than sorted children method. - } - } - NSAssert(!err, @"ERROR configAtIndex: Shouldn't happen. Something wrong with indexing."); - } - return changing; +- (BOOL)menuHasKeyEquivalent:(NSMenu *)menu forEvent:(NSEvent *)event target:(id _Nullable __autoreleasing *)target action:(SEL _Nullable *)action { + return NO; } -// Lazy populate the system bar menus when needed. +/// Perform a core data fatch request, store sorted object ids array and return object count. +- (NSInteger)numberOfItemsInMenu:(NSMenu*)menu { + NSMenuItem *parent = [menu.supermenu itemAtIndex:[menu.supermenu indexOfItemWithSubmenu:menu]]; + self.readContext = [StoreCoordinator createChildContext]; // will be deleted after menu:updateItem: + self.objectIDsForMenu = [StoreCoordinator sortedObjectIDsForParent:parent.representedObject isFeed:[menu isFeedMenu] inContext:self.readContext]; + return (NSInteger)[self.objectIDsForMenu count]; +} + +/// Lazy populate system bar menus when needed. - (BOOL)menu:(NSMenu*)menu updateItem:(NSMenuItem*)item atIndex:(NSInteger)index shouldCancel:(BOOL)shouldCancel { - NSManagedObjectID *moid = [self.currentOpenMenu objectAtIndex:(NSUInteger)index]; - id obj = [self.readContext objectWithID:moid]; - [self.readContext refreshObject:obj mergeChanges:YES]; - + id obj = [self.readContext objectWithID:[self.objectIDsForMenu objectAtIndex:(NSUInteger)index]]; if ([obj isKindOfClass:[FeedConfig class]]) { [item setFeedConfig:obj]; - if ([(FeedConfig*)obj typ] == FEED) { - item.target = self; - item.action = @selector(openFeedURL:); - } + if ([(FeedConfig*)obj typ] == FEED) + [item setTarget:self action:@selector(openFeedURL:)]; } else if ([obj isKindOfClass:[FeedItem class]]) { [item setFeedItem:obj]; - item.target = self; - item.action = @selector(openFeedURL:); + [item setTarget:self action:@selector(openFeedURL:)]; } - if (menu.numberOfItems == index + 1) { - int unreadCount = self.unreadCountTotal; // if parent == nil - if ([obj isKindOfClass:[FeedItem class]]) { - unreadCount = [[[(FeedItem*)obj feed] config] unreadCount]; - } else if ([(FeedConfig*)obj parent]) { - unreadCount = [[(FeedConfig*)obj parent] unreadCount]; - } - [self finalizeMenu:menu hasUnread:(unreadCount > 0)]; - self.currentOpenMenu = nil; + + if (index + 1 == menu.numberOfItems) { // last item of the menu + [self finalizeMenu:menu object:obj]; + self.objectIDsForMenu = nil; + [self.readContext reset]; + self.readContext = nil; } return YES; } /** - Add default menu items that are present in each menu as header. - - @param flag If @c NO, 'Open all unread' and 'Mark all read' will be disabled. + Add default menu items that are present in each menu as header and disable menu items if necessary */ -- (void)finalizeMenu:(NSMenu*)menu hasUnread:(BOOL)flag { - BOOL isMainMenu = [menu isMainMenu]; - MenuItemTag scope; - if (isMainMenu) scope = ScopeGlobal; - else if ([menu isFeedMenu]) scope = ScopeFeed; - else scope = ScopeGroup; - - [menu replaceSeparatorStringsWithActualSeparator]; - [self insertDefaultHeaderForAllMenus:menu scope:scope hasUnread:flag]; - if (isMainMenu) { - [self insertMainMenuHeader:menu]; +- (void)finalizeMenu:(NSMenu*)menu object:(id)obj { + NSInteger unreadCount = self.unreadCountTotal; // if parent == nil + if ([menu isFeedMenu]) { + unreadCount = [(FeedItem*)obj feed].unreadCount; + } else if (![menu isMainMenu]) { + unreadCount = [menu coreDataUnreadCount]; } + [menu replaceSeparatorStringsWithActualSeparator]; + [self insertDefaultHeaderForAllMenus:menu hasUnread:(unreadCount > 0)]; + if ([menu isMainMenu]) + [self insertMainMenuHeader:menu]; } /** @@ -265,11 +258,15 @@ @param flag If @c NO, 'Open all unread' and 'Mark all read' will be disabled. */ -- (void)insertDefaultHeaderForAllMenus:(NSMenu*)menu scope:(MenuItemTag)scope hasUnread:(BOOL)flag { - NSMenuItem *item1 = [self itemTitle:NSLocalizedString(@"Open all unread", nil) selector:@selector(openAllUnread:) tag:TagOpenAllUnread | scope]; +- (void)insertDefaultHeaderForAllMenus:(NSMenu*)menu hasUnread:(BOOL)flag { + MenuItemTag scope = [menu scope]; + NSMenuItem *item1 = [NSMenuItem itemWithTitle:NSLocalizedString(@"Open all unread", nil) + action:@selector(openAllUnread:) target:self tag:TagOpenAllUnread | scope]; NSMenuItem *item2 = [item1 alternateWithTitle:[NSString stringWithFormat:NSLocalizedString(@"Open a few unread (%d)", nil), 3]]; - NSMenuItem *item3 = [self itemTitle:NSLocalizedString(@"Mark all read", nil) selector:@selector(markAllReadOrUnread:) tag:TagMarkAllRead | scope]; - NSMenuItem *item4 = [self itemTitle:NSLocalizedString(@"Mark all unread", nil) selector:@selector(markAllReadOrUnread:) tag:TagMarkAllUnread | scope]; + NSMenuItem *item3 = [NSMenuItem itemWithTitle:NSLocalizedString(@"Mark all read", nil) + action:@selector(markAllReadOrUnread:) target:self tag:TagMarkAllRead | scope]; + NSMenuItem *item4 = [NSMenuItem itemWithTitle:NSLocalizedString(@"Mark all unread", nil) + action:@selector(markAllReadOrUnread:) target:self tag:TagMarkAllUnread | scope]; item1.enabled = flag; item2.enabled = flag; item3.enabled = flag; @@ -285,8 +282,10 @@ Insert default menu items for the main menu only. Like 'Pause Updates', 'Update all feeds', 'Preferences' and 'Quit'. */ - (void)insertMainMenuHeader:(NSMenu*)menu { - NSMenuItem *item1 = [self itemTitle:NSLocalizedString(@"Pause Updates", nil) selector:@selector(pauseUpdates:) tag:TagPauseUpdates]; - NSMenuItem *item2 = [self itemTitle:NSLocalizedString(@"Update all feeds", nil) selector:@selector(updateAllFeeds:) tag:TagUpdateFeed]; + NSMenuItem *item1 = [NSMenuItem itemWithTitle:NSLocalizedString(@"Pause Updates", nil) + action:@selector(pauseUpdates:) target:self tag:TagPauseUpdates]; + NSMenuItem *item2 = [NSMenuItem itemWithTitle:NSLocalizedString(@"Update all feeds", nil) + action:@selector(updateAllFeeds:) target:self tag:TagUpdateFeed]; if ([UserPrefs defaultYES:@"globalUpdateAll"] == NO) item2.hidden = YES; if (![FeedDownload isNetworkReachable]) @@ -296,23 +295,13 @@ [menu insertItem:[NSMenuItem separatorItem] atIndex:2]; // < feed content > [menu addItem:[NSMenuItem separatorItem]]; - NSMenuItem *prefs = [self itemTitle:NSLocalizedString(@"Preferences", nil) selector:@selector(openPreferences) tag:TagPreferences]; + NSMenuItem *prefs = [NSMenuItem itemWithTitle:NSLocalizedString(@"Preferences", nil) + action:@selector(openPreferences) target:self tag:TagPreferences]; prefs.keyEquivalent = @","; [menu addItem:prefs]; [menu addItemWithTitle:NSLocalizedString(@"Quit", nil) action:@selector(terminate:) keyEquivalent:@"q"]; } -/** - Helper method to generate a new @c NSMenuItem. - */ -- (NSMenuItem*)itemTitle:(NSString*)title selector:(SEL)selector tag:(MenuItemTag)tag { - NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:title action:selector keyEquivalent:@""]; - item.target = self; - item.tag = tag; - [item applyUserSettingsDisplay]; - return item; -} - #pragma mark - Menu Actions - @@ -365,23 +354,19 @@ NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; [sender iterateSorted:YES inContext:moc overDescendentFeeds:^(Feed *feed, BOOL *cancel) { - int itemSum = 0; - for (FeedItem *i in feed.items) { - if (itemSum >= maxItemCount) { - break; - } + for (FeedItem *i in [feed sortedArticles]) { // TODO: open oldest articles first? + if (maxItemCount <= 0) break; if (i.unread && i.link.length > 0) { [urls addObject:[NSURL URLWithString:i.link]]; i.unread = NO; - ++itemSum; + feed.unreadCount -= 1; + self.unreadCountTotal -= 1; + maxItemCount -= 1; } } - if (itemSum > 0) { - [feed.config markUnread:-itemSum ancestorsOnly:NO]; - maxItemCount -= itemSum; - } *cancel = (maxItemCount <= 0); }]; + [self updateBarIcon]; [self openURLsWithPreferredBrowser:urls]; [StoreCoordinator saveContext:moc andParent:YES]; [moc reset]; @@ -394,9 +379,9 @@ BOOL markRead = ((sender.tag & TagMaskType) == TagMarkAllRead); NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; [sender iterateSorted:NO inContext:moc overDescendentFeeds:^(Feed *feed, BOOL *cancel) { - if (markRead) [feed markAllItemsRead]; - else [feed markAllItemsUnread]; + self.unreadCountTotal += (markRead ? [feed markAllItemsRead] : [feed markAllItemsUnread]); }]; + [self updateBarIcon]; [StoreCoordinator saveContext:moc andParent:YES]; [moc reset]; } @@ -416,11 +401,13 @@ if ([obj isKindOfClass:[FeedConfig class]]) { url = [[(FeedConfig*)obj feed] link]; } else if ([obj isKindOfClass:[FeedItem class]]) { - FeedItem *feed = obj; - url = [feed link]; - if (feed.unread) { - feed.unread = NO; - [feed.feed.config markUnread:-1 ancestorsOnly:NO]; + FeedItem *item = obj; + url = [item link]; + if (item.unread) { + item.unread = NO; + item.feed.unreadCount -= 1; + self.unreadCountTotal -= 1; + [self updateBarIcon]; [StoreCoordinator saveContext:moc andParent:YES]; } } diff --git a/baRSS/Status Bar Menu/NSMenu+Ext.h b/baRSS/Status Bar Menu/NSMenu+Ext.h index bbccaa8..e0b1529 100644 --- a/baRSS/Status Bar Menu/NSMenu+Ext.h +++ b/baRSS/Status Bar Menu/NSMenu+Ext.h @@ -21,12 +21,20 @@ // SOFTWARE. #import +#import "NSMenuItem+Ext.h" @interface NSMenu (Ext) +// Generator + (instancetype)menuWithDelegate:(id)target; - (instancetype)submenuWithIndex:(int)index isFeed:(BOOL)flag; -- (void)replaceSeparatorStringsWithActualSeparator; +- (instancetype)cleanInstanceCopy; +// Properties - (BOOL)isMainMenu; - (BOOL)isFeedMenu; -- (NSInteger)getFeedConfigOffsetAndUpdateUnread:(BOOL)hasUnread; +- (MenuItemTag)scope; +- (NSInteger)feedConfigOffset; +- (NSInteger)coreDataUnreadCount; +// Modify menu +- (void)replaceSeparatorStringsWithActualSeparator; +- (void)autoEnableMenuHeader:(BOOL)hasUnread; @end diff --git a/baRSS/Status Bar Menu/NSMenu+Ext.m b/baRSS/Status Bar Menu/NSMenu+Ext.m index 41cdb10..585bf0f 100644 --- a/baRSS/Status Bar Menu/NSMenu+Ext.m +++ b/baRSS/Status Bar Menu/NSMenu+Ext.m @@ -21,10 +21,13 @@ // SOFTWARE. #import "NSMenu+Ext.h" -#import "NSMenuItem+Ext.h" +#import "StoreCoordinator.h" @implementation NSMenu (Ext) +#pragma mark - Generator - + +/// @return New main menu with target delegate. + (instancetype)menuWithDelegate:(id)target { NSMenu *menu = [[NSMenu alloc] initWithTitle:@"M"]; menu.autoenablesItems = NO; @@ -32,18 +35,82 @@ return menu; } +/// @return New menu with old title and delegate. Index path in title is appended. - (instancetype)submenuWithIndex:(int)index isFeed:(BOOL)flag { - NSMenu *menu = [NSMenu new]; + NSMenu *menu = [NSMenu menuWithDelegate:self.delegate]; menu.title = [NSString stringWithFormat:@"%c%@.%d", (flag ? 'F' : 'G'), self.title, index]; - menu.autoenablesItems = NO; - menu.delegate = self.delegate; return menu; } +/// @return New menu with old title and delegate. +- (instancetype)cleanInstanceCopy { + NSMenu *menu = [NSMenu menuWithDelegate:self.delegate]; + menu.title = self.title; + return menu; +} + + +#pragma mark - Properties - + + +/// @return @c YES if menu is status bar menu. +- (BOOL)isMainMenu { + return [self.title isEqualToString:@"M"]; +} + +/// @return @c YES if menu contains feed articles only. +- (BOOL)isFeedMenu { + return [self.title characterAtIndex:0] == 'F'; +} + +/// @return Either @c ScopeGlobal, @c ScopeGroup or @c ScopeFeed. +- (MenuItemTag)scope { + if ([self isFeedMenu]) return ScopeFeed; + if ([self isMainMenu]) return ScopeGlobal; + return ScopeGroup; +} + +/// @return Index offset of the first Core Data feed item (may be separator), skipping default header and main menu header. +- (NSInteger)feedConfigOffset { + for (NSInteger i = 0; i < self.numberOfItems; i++) { + if ([[[self itemAtIndex:i] representedObject] isKindOfClass:[NSManagedObjectID class]]) + return i; + } + return 0; +} + +/// Perform Core Data fetch request and return unread count for all descendent items. +- (NSInteger)coreDataUnreadCount { + NSUInteger loc = [self.title rangeOfString:@"."].location; + NSString *path = nil; + if (loc != NSNotFound) + path = [self.title substringFromIndex:loc + 1]; + return [StoreCoordinator unreadCountForIndexPathString:path]; +} + + +#pragma mark - Modify Menu - + + +/// Loop over default header and enable 'OpenAllUnread' and 'TagMarkAllRead' based on unread count. +- (void)autoEnableMenuHeader:(BOOL)hasUnread { + for (NSMenuItem *item in self.itemArray) { + if (item.representedObject) + return; // default menu has no represented object + switch (item.tag & TagMaskType) { + case TagOpenAllUnread: case TagMarkAllRead: + item.enabled = hasUnread; + default: break; + } + //[item applyUserSettingsDisplay]; // should not change while menu is open + } +} + +/// Loop over menu and replace all separator items (text) with actual separator. - (void)replaceSeparatorStringsWithActualSeparator { for (NSInteger i = 0; i < self.numberOfItems; i++) { NSMenuItem *oldItem = [self itemAtIndex:i]; - if ([oldItem.title isEqualToString:@"---SEPARATOR---"]) { + if ([oldItem.title isEqualToString:kSeparatorItemTitle]) { NSMenuItem *newItem = [NSMenuItem separatorItem]; newItem.representedObject = oldItem.representedObject; [self removeItemAtIndex:i]; @@ -52,50 +119,4 @@ } } -- (BOOL)isMainMenu { - return [self.title isEqualToString:@"M"]; -} - -- (BOOL)isFeedMenu { - return [self.title characterAtIndex:0] == 'F'; -} - -//- (void)iterateMenuItems:(void(^)(NSMenuItem*,BOOL))block atIndexPath:(NSIndexPath*)path { -// NSMenu *m = self; -// for (NSUInteger u = 0; u < path.length; u++) { -// NSUInteger i = [path indexAtPosition:u]; -// for (NSMenuItem *item in m.itemArray) { -// if (![item.representedObject isKindOfClass:[NSManagedObjectID class]]) { -// continue; // not a core data item -// } -// if (i == 0) { -// BOOL isFinalItem = (u == path.length - 1); -// block(item, isFinalItem); -// if (isFinalItem) return; // item found! -// m = item.submenu; -// break; // cancel evaluation of remaining items -// } -// i -= 1; -// } -// } -// return; // whenever a menu inbetween is nil (e.g., wasn't set yet) -//} - -- (NSInteger)getFeedConfigOffsetAndUpdateUnread:(BOOL)hasUnread { - for (NSInteger i = 0; i < self.numberOfItems; i++) { - NSMenuItem *item = [self itemAtIndex:i]; - if ([item.representedObject isKindOfClass:[NSManagedObjectID class]]) { - return i; - } else { - //[item applyUserSettingsDisplay]; // should not change while menu is open - switch (item.tag & TagMaskType) { - case TagOpenAllUnread: case TagMarkAllRead: - item.enabled = hasUnread; - default: break; - } - } - } - return 0; -} - @end diff --git a/baRSS/Status Bar Menu/NSMenuItem+Ext.h b/baRSS/Status Bar Menu/NSMenuItem+Ext.h index 86b08c2..ee684ef 100644 --- a/baRSS/Status Bar Menu/NSMenuItem+Ext.h +++ b/baRSS/Status Bar Menu/NSMenuItem+Ext.h @@ -22,6 +22,8 @@ #import +static NSString *kSeparatorItemTitle = @"---SEPARATOR---"; + /// @c NSMenuItem options that are assigned to the @c tag attribute. typedef NS_OPTIONS(NSInteger, MenuItemTag) { /// Item visible at the very first menu level @@ -45,11 +47,13 @@ typedef NS_OPTIONS(NSInteger, MenuItemTag) { @class FeedConfig, Feed, FeedItem; @interface NSMenuItem (Feed) ++ (NSMenuItem*)itemWithTitle:(NSString*)title action:(SEL)selector target:(id)target tag:(MenuItemTag)tag; - (NSMenuItem*)alternateWithTitle:(NSString*)title; +- (void)setTarget:(id)target action:(SEL)selector; - (void)setFeedConfig:(FeedConfig*)config; - (void)setFeedItem:(FeedItem*)item; -- (void)setTitleAndUnreadCount:(FeedConfig*)config; +- (NSInteger)setTitleAndUnreadCount:(FeedConfig*)config; + - (void)iterateSorted:(BOOL)ordered inContext:(NSManagedObjectContext*)moc overDescendentFeeds:(void(^)(Feed *feed, BOOL *cancel))block; -- (void)applyUserSettingsDisplay; @end diff --git a/baRSS/Status Bar Menu/NSMenuItem+Ext.m b/baRSS/Status Bar Menu/NSMenuItem+Ext.m index 5c7e02e..b544fe2 100644 --- a/baRSS/Status Bar Menu/NSMenuItem+Ext.m +++ b/baRSS/Status Bar Menu/NSMenuItem+Ext.m @@ -39,6 +39,19 @@ typedef NS_ENUM(char, DisplaySetting) { @implementation NSMenuItem (Feed) +#pragma mark - General helper methods - + +/** + Helper method to generate a new @c NSMenuItem. + */ ++ (NSMenuItem*)itemWithTitle:(NSString*)title action:(SEL)selector target:(id)target tag:(MenuItemTag)tag { + NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:title action:selector keyEquivalent:@""]; + item.target = target; + item.tag = tag; + [item applyUserSettingsDisplay]; + return item; +} + /** Create a copy of an existing menu item and set it's option key modifier. */ @@ -54,17 +67,31 @@ typedef NS_ENUM(char, DisplaySetting) { } /** - Set title based on preferences either with or without unread count in parenthesis. + Convenient method to set @c target and @c action simultaneously. */ -- (void)setTitleAndUnreadCount:(FeedConfig*)config { - if (config.unreadCount > 0 && - ((config.typ == FEED && [UserPrefs defaultYES:@"feedUnreadCount"]) || - (config.typ == GROUP && [UserPrefs defaultYES:@"groupUnreadCount"]))) - { - self.title = [NSString stringWithFormat:@"%@ (%d)", config.name, config.unreadCount]; - } else { - self.title = config.name; +- (void)setTarget:(id)target action:(SEL)selector { + self.target = target; + self.action = selector; +} + + +#pragma mark - Set properties based on Core Data object - + + +/** + Set title based on preferences either with or without unread count in parenthesis. + + @return Number of unread items. (@b warning: May return @c 0 if visibility is disabled in @c UserPrefs) + */ +- (NSInteger)setTitleAndUnreadCount:(FeedConfig*)config { + NSInteger uCount = 0; + if (config.typ == FEED && [UserPrefs defaultYES:@"feedUnreadCount"]) { + uCount = config.feed.unreadCount; + } else if (config.typ == GROUP && [UserPrefs defaultYES:@"groupUnreadCount"]) { + uCount = [self.submenu coreDataUnreadCount]; } + self.title = (uCount > 0 ? [NSString stringWithFormat:@"%@ (%ld)", config.name, uCount] : config.name); + return uCount; } /** @@ -73,10 +100,10 @@ typedef NS_ENUM(char, DisplaySetting) { - (void)setFeedConfig:(FeedConfig*)config { self.representedObject = config.objectID; if (config.typ == SEPARATOR) { - self.title = @"---SEPARATOR---"; + self.title = kSeparatorItemTitle; } else { - [self setTitleAndUnreadCount:config]; self.submenu = [self.menu submenuWithIndex:config.sortIndex isFeed:(config.typ == FEED)]; + [self setTitleAndUnreadCount:config]; // after submenu is set if (config.typ == FEED) { [self configureAsFeed:config]; } else { @@ -140,7 +167,7 @@ typedef NS_ENUM(char, DisplaySetting) { /** @return @c FeedConfig object if @c representedObject contains a valid @c NSManagedObjectID. */ -- (FeedConfig*)feedConfig:(NSManagedObjectContext*)moc { +- (FeedConfig*)requestConfig:(NSManagedObjectContext*)moc { if (!self.representedObject || ![self.representedObject isKindOfClass:[NSManagedObjectID class]]) return nil; FeedConfig *config = [moc objectWithID:self.representedObject]; @@ -157,10 +184,10 @@ typedef NS_ENUM(char, DisplaySetting) { */ - (void)iterateSorted:(BOOL)ordered inContext:(NSManagedObjectContext*)moc overDescendentFeeds:(void(^)(Feed*,BOOL*))block { if (self.parentItem) { - [[self.parentItem feedConfig:moc] iterateSorted:ordered overDescendantFeeds:block]; + [[self.parentItem requestConfig:moc] iterateSorted:ordered overDescendantFeeds:block]; } else { for (NSMenuItem *item in self.menu.itemArray) { - FeedConfig *fc = [item feedConfig:moc]; + FeedConfig *fc = [item requestConfig:moc]; if (fc != nil) { // All groups and feeds; Ignore default header if (![fc iterateSorted:ordered overDescendantFeeds:block]) return; diff --git a/baRSS/StoreCoordinator.h b/baRSS/StoreCoordinator.h index 1071ede..aa0443d 100644 --- a/baRSS/StoreCoordinator.h +++ b/baRSS/StoreCoordinator.h @@ -27,14 +27,16 @@ @class RSParsedFeed; @interface StoreCoordinator : NSObject -+ (NSManagedObjectContext*)getMainContext; +// Managing contexts + (NSManagedObjectContext*)createChildContext; + (void)saveContext:(NSManagedObjectContext*)context andParent:(BOOL)flag; -+ (NSArray*)sortedFeedConfigItemsInContext:(nonnull NSManagedObjectContext*)context; +// Feed update + (NSArray*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc; + (NSDate*)nextScheduledUpdate; -+ (int)totalNumberOfUnreadFeeds; +// Feed display ++ (NSInteger)unreadCountForIndexPathString:(NSString*)str; ++ (NSArray*)sortedObjectIDsForParent:(id)parent isFeed:(BOOL)flag inContext:(NSManagedObjectContext*)moc; // Restore sound state + (void)deleteUnreferencedFeeds; -+ (void)restoreUnreadCount; ++ (void)restoreFeedCountsAndIndexPaths; @end diff --git a/baRSS/StoreCoordinator.m b/baRSS/StoreCoordinator.m index b485c0d..49e03bf 100644 --- a/baRSS/StoreCoordinator.m +++ b/baRSS/StoreCoordinator.m @@ -26,6 +26,8 @@ @implementation StoreCoordinator +#pragma mark - Managing contexts - + + (NSManagedObjectContext*)getMainContext { return [(AppHook*)NSApp persistentContainer].viewContext; } @@ -53,20 +55,14 @@ } } -+ (NSArray*)sortedFeedConfigItemsInContext:(NSManagedObjectContext*)context { - NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedConfig.entity.name]; - fr.predicate = [NSPredicate predicateWithFormat:@"parent = NULL"]; // %@", parent - fr.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]]; - NSError *err; - NSArray *result = [context executeFetchRequest:fr error:&err]; - if (err) NSLog(@"%@", err); - return result; -} +#pragma mark - Feed Update - + (NSArray*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc { + // TODO: Get Feed instead of FeedConfig NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedConfig.entity.name]; if (!forceAll) { - fr.predicate = [NSPredicate predicateWithFormat:@"type = %d AND scheduled <= %@", FEED, [NSDate date]]; + // when fetching also get those feeds that would need update soon (now + 30s) + fr.predicate = [NSPredicate predicateWithFormat:@"type = %d AND scheduled <= %@", FEED, [NSDate dateWithTimeIntervalSinceNow:+30]]; } else { fr.predicate = [NSPredicate predicateWithFormat:@"type = %d", FEED]; } @@ -97,7 +93,9 @@ return fetchResults.firstObject[@"earliestDate"]; // can be nil } -+ (int)totalNumberOfUnreadFeeds { +#pragma mark - Feed Display - + ++ (NSInteger)unreadCountForIndexPathString:(NSString*)str { // Always get context first, or 'FeedConfig.entity.name' may not be available on app start NSManagedObjectContext *moc = [self getMainContext]; NSExpression *exp = [NSExpression expressionForFunction:@"sum:" @@ -107,15 +105,29 @@ [expDesc setExpression:exp]; [expDesc setExpressionResultType:NSInteger32AttributeType]; - NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedConfig.entity.name]; - fr.predicate = [NSPredicate predicateWithFormat:@"type = %d", FEED]; + NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name]; + if (str && str.length > 0) + fr.predicate = [NSPredicate predicateWithFormat:@"indexPath BEGINSWITH %@", str]; [fr setResultType:NSDictionaryResultType]; [fr setPropertiesToFetch:@[expDesc]]; NSError *err; NSArray *fetchResults = [moc executeFetchRequest:fr error:&err]; if (err) NSLog(@"%@", err); - return [fetchResults.firstObject[@"totalUnread"] intValue]; + return [fetchResults.firstObject[@"totalUnread"] integerValue]; +} + ++ (NSArray*)sortedObjectIDsForParent:(id)parent isFeed:(BOOL)flag inContext:(NSManagedObjectContext*)moc { +// NSManagedObjectContext *moc = [self getMainContext]; + NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: (flag ? FeedItem.entity : FeedConfig.entity).name]; + fr.predicate = [NSPredicate predicateWithFormat:(flag ? @"feed.config = %@" : @"parent = %@"), parent]; + fr.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:!flag]]; + [fr setResultType:NSManagedObjectIDResultType]; + + NSError *err; + NSArray *fetchResults = [moc executeFetchRequest:fr error:&err]; + if (err) NSLog(@"%@", err); + return fetchResults; } //+ (void)addToSortIndex:(int)num start:(int)index parent:(FeedConfig*)config inContext:(NSManagedObjectContext*)moc { @@ -142,27 +154,23 @@ if (err) NSLog(@"%@", err); } -+ (void)restoreUnreadCount { ++ (void)restoreFeedCountsAndIndexPaths { NSManagedObjectContext *moc = [self getMainContext]; NSError *err; - NSArray *confs = [moc executeFetchRequest:[NSFetchRequest fetchRequestWithEntityName: FeedConfig.entity.name] error:&err]; - if (err) NSLog(@"%@", err); - NSArray *feeds = [moc executeFetchRequest:[NSFetchRequest fetchRequestWithEntityName: Feed.entity.name] error:&err]; + NSArray *result = [moc executeFetchRequest:[NSFetchRequest fetchRequestWithEntityName: Feed.entity.name] error:&err]; if (err) NSLog(@"%@", err); [moc performBlock:^{ - for (FeedConfig *conf in confs) { - conf.unreadCount = 0; - } - for (Feed *feed in feeds) { - int count = 0; - for (FeedItem *item in feed.items) { - if (item.unread) ++count; - } - FeedConfig *parent = feed.config; - while (parent) { - parent.unreadCount += count; - parent = parent.parent; - } + for (Feed *feed in result) { + int16_t totalCount = (int16_t)feed.items.count; + int16_t unreadCount = (int16_t)[[feed.items valueForKeyPath:@"@sum.unread"] integerValue]; + if (feed.articleCount != totalCount) + feed.articleCount = totalCount; + if (feed.unreadCount != unreadCount) + feed.unreadCount = unreadCount; // remember to update global total unread count + + NSString *pathStr = [feed.config indexPathString]; + if (![feed.indexPath isEqualToString:pathStr]) + feed.indexPath = pathStr; } }]; }