From d5354bb681cd35a70985c84948473f63d8db20d2 Mon Sep 17 00:00:00 2001 From: relikd Date: Fri, 25 Jan 2019 02:12:15 +0100 Subject: [PATCH] FeedDownload refactoring: everything parallel! --- README.md | 7 +- baRSS/Categories/Feed+Ext.h | 4 +- baRSS/Categories/Feed+Ext.m | 50 ++-- baRSS/Constants.h | 31 +++ .../DBv1.xcdatamodel/contents | 5 +- baRSS/FeedDownload.h | 12 +- baRSS/FeedDownload.m | 255 ++++++++---------- baRSS/Preferences/Feeds Tab/ModalFeedEdit.m | 3 +- baRSS/Preferences/Feeds Tab/OpmlExport.h | 2 +- baRSS/Preferences/Feeds Tab/OpmlExport.m | 111 ++++---- baRSS/Preferences/Feeds Tab/SettingsFeeds.m | 75 +++++- baRSS/Preferences/Feeds Tab/SettingsFeeds.xib | 45 ++-- .../Preferences/General Tab/SettingsGeneral.m | 7 +- baRSS/Status Bar Menu/BarMenu.h | 1 - baRSS/Status Bar Menu/BarMenu.m | 72 +++-- baRSS/StoreCoordinator.h | 5 +- baRSS/StoreCoordinator.m | 72 +++-- 17 files changed, 427 insertions(+), 330 deletions(-) diff --git a/README.md b/README.md index e8ab0c5..c0487bf 100644 --- a/README.md +++ b/README.md @@ -35,14 +35,9 @@ All in all, the software is in a usable state. The remaining features will be ad ToDo ---- -- [ ] Preferences - - [ ] ~~Choose status bar icon?~~ - - [ ] Display license info (e.g., RSXML) - - - [ ] Edit feed - [ ] Show statistics - - [ ] How often gets the feed updated (min, max, avg) + - [x] How often gets the feed updated (min, max, avg) - [ ] Automatically choose best interval? - [ ] Show time of next update - [ ] Feeds with authentication diff --git a/baRSS/Categories/Feed+Ext.h b/baRSS/Categories/Feed+Ext.h index 9e5c67b..378a877 100644 --- a/baRSS/Categories/Feed+Ext.h +++ b/baRSS/Categories/Feed+Ext.h @@ -29,7 +29,7 @@ + (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context; + (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc; - (void)calculateAndSetIndexPathString; -- (void)resetArticleCountAndIndexPathString; +- (void)calculateAndSetUnreadCount; - (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag; // Article properties - (NSArray*)sortedArticles; @@ -37,5 +37,5 @@ - (int)markAllItemsUnread; // Icon - (NSImage*)iconImage16; -- (BOOL)setIcon:(NSImage*)img replaceExisting:(BOOL)overwrite; +- (BOOL)setIconImage:(NSImage*)img; @end diff --git a/baRSS/Categories/Feed+Ext.m b/baRSS/Categories/Feed+Ext.m index e532da7..dc110a5 100644 --- a/baRSS/Categories/Feed+Ext.m +++ b/baRSS/Categories/Feed+Ext.m @@ -57,15 +57,11 @@ self.indexPath = pthStr; } -/// Reset attributes @c articleCount, @c unreadCount, and @c indexPath. -- (void)resetArticleCountAndIndexPathString { - int16_t totalCount = (int16_t)self.articles.count; - int16_t unreadCount = (int16_t)[[self.articles valueForKeyPath:@"@sum.unread"] integerValue]; - if (self.articleCount != totalCount) - self.articleCount = totalCount; +/// Reset attributes @c unreadCount by counting number of articles. @note Remember to update global unread count. +- (void)calculateAndSetUnreadCount { + int32_t unreadCount = (int32_t)[[self.articles valueForKeyPath:@"@sum.unread"] integerValue]; if (self.unreadCount != unreadCount) - self.unreadCount = unreadCount; // remember to update global total unread count - [self calculateAndSetIndexPathString]; + self.unreadCount = unreadCount; } @@ -89,12 +85,10 @@ [self addMissingArticles:obj updateLinks:urls]; // will remove links in 'urls' that should be kept [self deleteArticlesWithLink:urls]; // remove old, outdated articles // Get new total article count and post unread-count-change notification - int32_t totalCount = (int32_t)self.articles.count; - if (self.articleCount != totalCount) - self.articleCount = totalCount; if (flag) { - NSNumber *cDiff = [NSNumber numberWithInteger:self.unreadCount - unreadBefore]; - [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged object:cDiff]; + int32_t cDiff = self.unreadCount - unreadBefore; + if (cDiff != 0) + [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged object:@(cDiff)]; } } @@ -237,7 +231,7 @@ fa.unread = !readFlag; } int32_t oldCount = self.unreadCount; - int32_t newCount = (readFlag ? 0 : self.articleCount); + int32_t newCount = (readFlag ? 0 : (int32_t)self.articles.count); if (self.unreadCount != newCount) self.unreadCount = newCount; return newCount - oldCount; @@ -258,7 +252,7 @@ [img setSize:NSMakeSize(16, 16)]; return img; } - else if (self.articleCount == 0) + else if (self.articles.count == 0) { static NSImage *warningIcon; if (!warningIcon) { @@ -277,22 +271,20 @@ } /** - Set (or overwrite) favicon icon or delete relationship if icon is @c nil. + Set favicon icon or delete relationship if @c img is not a valid image. - @param overwrite If @c NO write image only if non is set already. Use @c YES if you want to @c nil. + @return @c YES if icon was updated (core data did change). */ -- (BOOL)setIcon:(NSImage*)img replaceExisting:(BOOL)overwrite { - if (overwrite || !self.icon) { // write if forced or image empty - if (img && [img isValid]) { - if (!self.icon) - self.icon = [[FeedIcon alloc] initWithEntity:FeedIcon.entity insertIntoManagedObjectContext:self.managedObjectContext]; - self.icon.icon = [img TIFFRepresentation]; - return YES; - } else if (self.icon) { - [self.managedObjectContext deleteObject:self.icon]; - self.icon = nil; - return YES; - } +- (BOOL)setIconImage:(NSImage*)img { + if (img && [img isValid]) { + if (!self.icon) + self.icon = [[FeedIcon alloc] initWithEntity:FeedIcon.entity insertIntoManagedObjectContext:self.managedObjectContext]; + self.icon.icon = [img TIFFRepresentation]; + return YES; + } else if (self.icon) { + [self.managedObjectContext deleteObject:self.icon]; + self.icon = nil; + return YES; } return NO; } diff --git a/baRSS/Constants.h b/baRSS/Constants.h index 1df49ff..5e3b889 100644 --- a/baRSS/Constants.h +++ b/baRSS/Constants.h @@ -29,11 +29,42 @@ // TODO: List of hidden preferences for readme // TODO: Do we need to search for favicon in places other than '../favicon.ico'? +/** + @c notification.object is @c NSNumber of type @c NSUInteger. + Represents number of feeds that are proccessed in background update. Sends @c 0 when all downloads are finished. + */ +static NSString *kNotificationBackgroundUpdateInProgress = @"baRSS-notification-background-update-in-progress"; +/** + @c notification.object is @c NSManagedObjectID of type @c Feed. + Called whenever download of a feed finished and object was modified (not if statusCode 304). + */ static NSString *kNotificationFeedUpdated = @"baRSS-notification-feed-updated"; +/** + @c notification.object is @c NSManagedObjectID of type @c Feed. + Called whenever the icon attribute of an item was updated. + */ +static NSString *kNotificationFeedIconUpdated = @"baRSS-notification-feed-icon-updated"; +/** + @c notification.object is @c NSNumber of type @c BOOL. + @c YES if network became reachable. @c NO on connection lost. + */ static NSString *kNotificationNetworkStatusChanged = @"baRSS-notification-network-status-changed"; +/** + @c notification.object is @c NSNumber of type @c NSInteger. + Represents a relative change (e.g., negative if items were marked read) + */ static NSString *kNotificationTotalUnreadCountChanged = @"baRSS-notification-total-unread-count-changed"; +/** + @c notification.object is either @c nil or @c NSNumber of type @c NSInteger. + If new count is known an absoulte number is passed. + Else @c nil if count has to be fetched from core data. + */ static NSString *kNotificationTotalUnreadCountReset = @"baRSS-notification-total-unread-count-reset"; + +/** + Internal developer method for benchmarking purposes. + */ 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)); diff --git a/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents b/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents index 5cdf402..5243533 100644 --- a/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents +++ b/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents @@ -1,7 +1,6 @@ - + - @@ -48,7 +47,7 @@ - + diff --git a/baRSS/FeedDownload.h b/baRSS/FeedDownload.h index 296a84a..7b810d7 100644 --- a/baRSS/FeedDownload.h +++ b/baRSS/FeedDownload.h @@ -26,6 +26,10 @@ @class Feed; @interface FeedDownload : NSObject +@property (class, readonly) BOOL allowNetworkConnection; +@property (class, readonly) BOOL isUpdating; +@property (class, setter=setPaused:) BOOL isPaused; + // Register for network change notifications + (void)registerNetworkChangeNotification; + (void)unregisterNetworkChangeNotification; @@ -35,12 +39,8 @@ // Downloading + (void)newFeed:(NSString *)urlStr askUser:(nonnull NSString*(^)(NSArray *list))askUser block:(nonnull void(^)(RSParsedFeed *parsed, NSError *error, NSHTTPURLResponse *response))block; + (void)autoDownloadAndParseURL:(NSString*)urlStr; -+ (void)batchDownloadRSSAndFavicons:(NSArray *)list showErrorAlert:(BOOL)flag rssFinished:(void(^)(NSArray *successful, BOOL *cancelFavicons))blockXml finally:(void(^)(BOOL successful))blockFavicon; -+ (void)downloadFavicon:(NSString*)urlStr finished:(void(^)(NSImage * _Nullable img))block ; -// User interaction -+ (BOOL)allowNetworkConnection; -+ (BOOL)isPaused; -+ (void)setPaused:(BOOL)flag; ++ (void)downloadFavicon:(NSString*)urlStr finished:(void(^)(NSImage * _Nullable img))block; ++ (void)batchDownloadFeeds:(NSArray *)list favicons:(BOOL)fav showErrorAlert:(BOOL)alert finally:(nullable os_block_t)block; @end diff --git a/baRSS/FeedDownload.m b/baRSS/FeedDownload.m index 49f58d9..864e851 100644 --- a/baRSS/FeedDownload.m +++ b/baRSS/FeedDownload.m @@ -31,23 +31,24 @@ static SCNetworkReachabilityRef _reachability = NULL; static BOOL _isReachable = NO; +static BOOL _isUpdating = NO; static BOOL _updatePaused = NO; static BOOL _nextUpdateIsForced = NO; @implementation FeedDownload + #pragma mark - User Interaction - /// @return @c YES if current network state is reachable and updates are not paused by user. -+ (BOOL)allowNetworkConnection { - return (_isReachable && !_updatePaused); -} ++ (BOOL)allowNetworkConnection { return (_isReachable && !_updatePaused); } + +/// @return @c YES if batch update is running ++ (BOOL)isUpdating { return _isUpdating; } /// @return @c YES if update is paused by user. -+ (BOOL)isPaused { - return _updatePaused; -} ++ (BOOL)isPaused { return _updatePaused; } /// Set paused flag and cancel timer regardless of network connectivity. + (void)setPaused:(BOOL)flag { @@ -94,7 +95,7 @@ static BOOL _nextUpdateIsForced = NO; if (![self allowNetworkConnection]) // timer will restart once connection exists return; _nextUpdateIsForced = YES; - [self scheduleTimer:[NSDate dateWithTimeIntervalSinceNow:0.2]]; + [self scheduleTimer:[NSDate dateWithTimeIntervalSinceNow:0.05]]; } /** @@ -130,31 +131,13 @@ static BOOL _nextUpdateIsForced = NO; NSArray *list = [StoreCoordinator getListOfFeedsThatNeedUpdate:updateAll inContext:moc]; //NSAssert(list.count > 0, @"ERROR: Something went wrong, timer fired too early."); - [FeedDownload batchUpdateFeeds:list showErrorAlert:NO finally:^(NSArray *successful, NSArray *failed) { - [self saveContext:moc andPostChanges:successful]; + [self batchDownloadFeeds:list favicons:updateAll showErrorAlert:NO finally:^{ + [StoreCoordinator saveContext:moc andParent:YES]; // save parents too ... [moc reset]; - if (updateAll) { // forced update will also download missing feed icons - NSArray *missingIcons = [StoreCoordinator listOfFeedsMissingIconsInContext:moc]; - [self batchDownloadFavicons:missingIcons replaceExisting:NO finally:^{ - [self saveContext:moc andPostChanges:successful]; - [moc reset]; - }]; - } [self resumeUpdates]; // always reset the timer }]; } -/** - Perform save on context and all parents. Then post @c FeedUpdated notification. - */ -+ (void)saveContext:(NSManagedObjectContext*)moc andPostChanges:(NSArray*)changedFeeds { - [StoreCoordinator saveContext:moc andParent:YES]; - if (changedFeeds && changedFeeds.count > 0) { - NSArray *list = [changedFeeds valueForKeyPath:@"objectID"]; - [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:list]; - } -} - #pragma mark - Request Generator - @@ -179,20 +162,20 @@ static BOOL _nextUpdateIsForced = NO; req.cachePolicy = NSURLRequestReloadIgnoringLocalCacheData; req.HTTPShouldHandleCookies = NO; // req.timeoutInterval = 30; -// [req setValue:@"Mon, 10 Sep 2018 10:32:19 GMT" forHTTPHeaderField:@"If-Modified-Since"]; -// [req setValue:@"wII2pETT9EGmlqyCHBFJpm25/7w" forHTTPHeaderField:@"If-None-Match"]; // ETag return req; } -/// @return New request with etag and modified headers set. -+ (NSURLRequest*)newRequest:(FeedMeta*)meta { +/// @return New request with etag and modified headers set (or not, if @c flag @c == @c YES ). ++ (NSURLRequest*)newRequest:(FeedMeta*)meta ignoreCache:(BOOL)flag { NSMutableURLRequest *req = [self newRequestURL:meta.url]; - NSString* etag = [meta.etag stringByReplacingOccurrencesOfString:@"-gzip" withString:@""]; - if (meta.modified.length > 0) - [req setValue:meta.modified forHTTPHeaderField:@"If-Modified-Since"]; - if (etag.length > 0) - [req setValue:etag forHTTPHeaderField:@"If-None-Match"]; // ETag - if (!_nextUpdateIsForced) // any FeedMeta-request that is not forced, is a background update + if (!flag) { + NSString* etag = [meta.etag stringByReplacingOccurrencesOfString:@"-gzip" withString:@""]; + if (meta.modified.length > 0) + [req setValue:meta.modified forHTTPHeaderField:@"If-Modified-Since"]; + if (etag.length > 0) + [req setValue:etag forHTTPHeaderField:@"If-None-Match"]; // ETag + } + if (!_nextUpdateIsForced) // any request that is not forced, is a background update req.networkServiceType = NSURLNetworkServiceTypeBackground; return req; } @@ -246,7 +229,7 @@ static BOOL _nextUpdateIsForced = NO; @note @c askUser will not be called if url is XML already. @param urlStr XML URL or HTTP URL that will be parsed to find feed URLs. - @param askUser Use @c list to present user a list of detected feed URLs. Always before @c block. + @param askUser Use @c list to present user a list of detected feed URLs. @param block Called after webpage has been fully parsed (including html autodetect). */ + (void)newFeed:(NSString *)urlStr askUser:(nonnull NSString*(^)(NSArray *list))askUser block:(nonnull void(^)(RSParsedFeed *parsed, NSError *error, NSHTTPURLResponse *response))block { @@ -273,41 +256,46 @@ static BOOL _nextUpdateIsForced = NO; } /** - Start download request with existing @c Feed object. Reuses etag and modified headers. - - @param feed @c Feed on which the update is executed. - @param group Mutex to count completion of all downloads. + Start download request with existing @c Feed object. Reuses etag and modified headers (unless articles count is 0). + + @note Will post a @c kNotificationFeedUpdated notification if download was successful and @b not status code 304. + @param alert If @c YES display Error Popup to user. - @param successful Empty, mutable list that will be returned in @c batchUpdateFeeds:finally:showErrorAlert: finally block - @param failed Empty, mutable list that will be returned in @c batchUpdateFeeds:finally:showErrorAlert: finally block + @param block Parameter @c success is only @c YES if download was successful or if status code is 304 (not modified). */ -+ (void)downloadFeed:(Feed*)feed group:(dispatch_group_t)group - errorAlert:(BOOL)alert - successful:(nonnull NSMutableArray*)successful - failed:(nonnull NSMutableArray*)failed -{ ++ (void)backgroundUpdateFeed:(Feed*)feed showErrorAlert:(BOOL)alert finally:(nullable void(^)(BOOL success))block { if (![self allowNetworkConnection]) { - [failed addObject:feed]; + if (block) block(NO); return; } - dispatch_group_enter(group); - [self parseFeedRequest:[self newRequest:feed.meta] xmlBlock:nil feedBlock:^(RSParsedFeed *rss, NSError *error, NSHTTPURLResponse *response) { - if (!feed.isDeleted) { - if (error) { - if (alert) { - NSAlert *alertPopup = [NSAlert alertWithError:error]; - alertPopup.informativeText = [NSString stringWithFormat:@"Error loading source: %@", response.URL.absoluteString]; - [alertPopup runModal]; - } - [feed.meta setErrorAndPostponeSchedule]; - [failed addObject:feed]; - } else { - [feed.meta setSucessfulWithResponse:response]; - if (rss) [feed updateWithRSS:rss postUnreadCountChange:YES]; - [successful addObject:feed]; // will be added even if statusCode == 304 (rss == nil) + NSManagedObjectID *oid = feed.objectID; + NSManagedObjectContext *moc = feed.managedObjectContext; + NSURLRequest *req = [self newRequest:feed.meta ignoreCache:(feed.articles.count == 0)]; + NSString *reqURL = req.URL.absoluteString; + [self parseFeedRequest:req xmlBlock:nil feedBlock:^(RSParsedFeed *rss, NSError *error, NSHTTPURLResponse *response) { + Feed *f = [moc objectWithID:oid]; + BOOL success = NO; + BOOL needsNotification = NO; + if (error) { + if (alert) { + NSAlert *alertPopup = [NSAlert alertWithError:error]; + alertPopup.informativeText = [NSString stringWithFormat:@"Error loading source: %@", reqURL]; + [alertPopup runModal]; + } + // TODO: don't increase error count on forced update + [f.meta setErrorAndPostponeSchedule]; + } else { + success = YES; + [f.meta setSucessfulWithResponse:response]; + if (rss) { + [f updateWithRSS:rss postUnreadCountChange:YES]; + needsNotification = YES; } } - dispatch_group_leave(group); + [StoreCoordinator saveContext:moc andParent:NO]; + if (needsNotification) + [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:oid]; + if (block) block(success); }]; } @@ -322,113 +310,88 @@ static BOOL _nextUpdateIsForced = NO; NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; Feed *f = [Feed appendToRootWithDefaultIntervalInContext:moc]; f.meta.url = url; - [self batchDownloadRSSAndFavicons:@[f] showErrorAlert:YES rssFinished:^(NSArray *successful, BOOL *cancelFavicons) { - if (successful.count == 0) { - *cancelFavicons = YES; - } else { - [self saveContext:moc andPostChanges:successful]; - } - } finally:^(BOOL successful) { - if (successful) { - [StoreCoordinator saveContext:moc andParent:YES]; - } else { - [moc rollback]; + [self backgroundUpdateBoth:f favicon:YES alert:YES finally:^(BOOL successful){ + if (!successful) { + [moc deleteObject:f.group]; } + [StoreCoordinator saveContext:moc andParent:YES]; [moc reset]; }]; } /** - Perform a download /update request for the feed data and download missing favicons. - If neither block is set, favicons will be downloaded and stored automatically. - However, you should handle the case - - @param list List of feeds that need update. Its sufficient if @c feed.meta.url is set. - @param flag If @c YES display Error Popup to user. - @param blockXml Called after XML is downloaded and parsed. - Parameter @c successful is list of feeds that were downloaded. - Set @c cancelFavicons to @c YES to call @c finally block without downloading favicons. Default: @c NO. - @param blockFavicon Called after all downloads are finished. - @c successful is set to @c NO if favicon download was prohibited in @c blockXml or list is empty. + Start download of feed xml, then continue with favicon download (optional). + + @param fav If @c YES continue with favicon download after xml download finished. + @param alert If @c YES display Error Popup to user. + @param block Parameter @c success is @c YES if xml download succeeded (regardless of favicon result). */ -+ (void)batchDownloadRSSAndFavicons:(NSArray *)list - showErrorAlert:(BOOL)flag - rssFinished:(void(^)(NSArray *successful, BOOL * cancelFavicons))blockXml - finally:(void(^)(BOOL successful))blockFavicon -{ - [self batchUpdateFeeds:list showErrorAlert:flag finally:^(NSArray *successful, NSArray *failed) { - BOOL cancelFaviconsDownload = NO; - if (blockXml) { - blockXml(successful, &cancelFaviconsDownload); - } - if (cancelFaviconsDownload || successful.count == 0) { - if (blockFavicon) blockFavicon(NO); - } else { - [self batchDownloadFavicons:successful replaceExisting:NO finally:^{ - if (blockFavicon) blockFavicon(YES); ++ (void)backgroundUpdateBoth:(Feed*)feed favicon:(BOOL)fav alert:(BOOL)alert finally:(nullable void(^)(BOOL success))block { + [self backgroundUpdateFeed:feed showErrorAlert:alert finally:^(BOOL success) { + if (fav && success) { + [self backgroundUpdateFavicon:feed replaceExisting:NO finally:^{ + if (block) block(YES); }]; + } else { + if (block) block(success); } }]; } /** - Create download list of feed URLs and download them all at once. Finally, notify when all finished. - + Start download of all feeds in list. Either with or without favicons. + @param list Download list using @c feed.meta.url as download url. (while reusing etag and modified headers) - @param flag If @c YES display Error Popup to user. - @param block Called after all downloads finished @b OR if list is empty (in that case both parameters are @c nil ). - */ -+ (void)batchUpdateFeeds:(NSArray *)list showErrorAlert:(BOOL)flag finally:(void(^)(NSArray *successful, NSArray *failed))block { - if (!list || list.count == 0) { - if (block) block(nil, nil); - return; - } - // else, process all feed items in a batch - NSMutableArray *successful = [NSMutableArray arrayWithCapacity:list.count]; - NSMutableArray *failed = [NSMutableArray arrayWithCapacity:list.count]; - - dispatch_group_t group = dispatch_group_create(); - for (Feed *feed in list) { - [self downloadFeed:feed group:group errorAlert:flag successful:successful failed:failed]; - } - dispatch_group_notify(group, dispatch_get_main_queue(), ^{ - if (block) block(successful, failed); - }); -} - - -#pragma mark - Favicon - - - -/** - Create download list of @c favicon.ico URLs and save downloaded images to persistent store. - - @param list Download list using @c feed.link as download url. If empty fall back to @c feed.meta.url - @param flag If @c YES display Error Popup to user. + @param fav If @c YES continue with favicon download after xml download finished. + @param alert If @c YES display Error Popup to user. @param block Called after all downloads finished. */ -+ (void)batchDownloadFavicons:(NSArray *)list replaceExisting:(BOOL)flag finally:(os_block_t)block { ++ (void)batchDownloadFeeds:(NSArray *)list favicons:(BOOL)fav showErrorAlert:(BOOL)alert finally:(nullable os_block_t)block { + _isUpdating = YES; + [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationBackgroundUpdateInProgress object:@(list.count)]; dispatch_group_t group = dispatch_group_create(); for (Feed *f in list) { - if (!flag && f.icon != nil) { - continue; // skip existing icons if replace == NO - } - NSManagedObjectID *oid = f.objectID; - NSManagedObjectContext *moc = f.managedObjectContext; - NSString *faviconURL = (f.link.length > 0 ? f.link : f.meta.url); - dispatch_group_enter(group); - [self downloadFavicon:faviconURL finished:^(NSImage *img) { - Feed *feed = [moc objectWithID:oid]; // should also work if context was reset - [feed setIcon:img replaceExisting:flag]; + [self backgroundUpdateBoth:f favicon:fav alert:alert finally:^(BOOL success){ dispatch_group_leave(group); }]; } dispatch_group_notify(group, dispatch_get_main_queue(), ^{ if (block) block(); + _isUpdating = NO; + [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationBackgroundUpdateInProgress object:@(0)]; }); } + +#pragma mark - Download Favicon - + + +/** + Start favicon download request on existing @c Feed object. + + @note Will post a @c kNotificationFeedIconUpdated notification if icon was updated. + + @param overwrite If @c YES and icon is present already, @c block will return immediatelly. + */ ++ (void)backgroundUpdateFavicon:(Feed*)feed replaceExisting:(BOOL)overwrite finally:(nullable os_block_t)block { + if (!overwrite && feed.icon != nil) { + if (block) block(); + return; // skip existing icons if replace == NO + } + NSManagedObjectID *oid = feed.objectID; + NSManagedObjectContext *moc = feed.managedObjectContext; + NSString *faviconURL = (feed.link.length > 0 ? feed.link : feed.meta.url); + [self downloadFavicon:faviconURL finished:^(NSImage *img) { + Feed *f = [moc objectWithID:oid]; + if (f && [f setIconImage:img]) { + [StoreCoordinator saveContext:moc andParent:NO]; + [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedIconUpdated object:oid]; + } + if (block) block(); + }]; +} + /// Download favicon located at http://.../ @c favicon.ico. Callback @c block will be called on main thread. + (void)downloadFavicon:(NSString*)urlStr finished:(void(^)(NSImage * _Nullable img))block { dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ diff --git a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m index 48b8fcc..11caa75 100644 --- a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m +++ b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m @@ -126,7 +126,7 @@ if (self.didDownloadFeed) { [meta setEtag:self.httpEtag modified:self.httpDate]; [feed updateWithRSS:self.feedResult postUnreadCountChange:YES]; - [feed setIcon:self.favicon replaceExisting:YES]; + [feed setIconImage:self.favicon]; } } @@ -161,7 +161,6 @@ if (self.modalSheet.didCloseAndCancel) return; [self preDownload]; - // TODO: parse webpage to find feed links instead (automatic link detection) [FeedDownload newFeed:self.previousURL askUser:^NSString *(NSArray *list) { return [self letUserChooseXmlUrlFromList:list]; } block:^(RSParsedFeed *result, NSError *error, NSHTTPURLResponse* response) { diff --git a/baRSS/Preferences/Feeds Tab/OpmlExport.h b/baRSS/Preferences/Feeds Tab/OpmlExport.h index 8baea0e..49e0905 100644 --- a/baRSS/Preferences/Feeds Tab/OpmlExport.h +++ b/baRSS/Preferences/Feeds Tab/OpmlExport.h @@ -26,6 +26,6 @@ @class Feed; @interface OpmlExport : NSObject -+ (void)showImportDialog:(NSWindow*)window withTreeController:(NSTreeController*)tree; ++ (void)showImportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc; + (void)showExportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc; @end diff --git a/baRSS/Preferences/Feeds Tab/OpmlExport.m b/baRSS/Preferences/Feeds Tab/OpmlExport.m index 02bd667..927c91d 100644 --- a/baRSS/Preferences/Feeds Tab/OpmlExport.m +++ b/baRSS/Preferences/Feeds Tab/OpmlExport.m @@ -31,13 +31,22 @@ #pragma mark - Open & Save Panel -/// Display Open File Panel to select @c .opml file. -+ (void)showImportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc success:(nullable void(^)(NSArray *added))block { +/// Display Open File Panel to select @c .opml file. Perform web requests (feed data & icon) within a single undo group. ++ (void)showImportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc { NSOpenPanel *op = [NSOpenPanel openPanel]; op.allowedFileTypes = @[@"opml"]; [op beginSheetModalForWindow:window completionHandler:^(NSModalResponse result) { if (result == NSModalResponseOK) { - [self importFeedData:op.URL inContext:moc success:block]; + NSData *data = [NSData dataWithContentsOfURL:op.URL]; + RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:@"opml-file-import"]; + RSOPMLParser *parser = [RSOPMLParser parserWithXMLData:xml]; + [parser parseAsync:^(RSOPMLItem * _Nullable doc, NSError * _Nullable error) { + if (error) { + [NSApp presentError:error]; + } else { + [self importOPMLDocument:doc inContext:moc]; + } + }]; } }]; } @@ -67,28 +76,6 @@ }]; } -/// Handle import dialog and perform web requests (feed data & icon). Creates a single undo group. -+ (void)showImportDialog:(NSWindow*)window withTreeController:(NSTreeController*)tree { - NSManagedObjectContext *moc = tree.managedObjectContext; - //[moc refreshAllObjects]; - [moc.undoManager beginUndoGrouping]; - [self showImportDialog:window withContext:moc success:^(NSArray *added) { - [StoreCoordinator saveContext:moc andParent:YES]; - [FeedDownload batchDownloadRSSAndFavicons:added showErrorAlert:YES rssFinished:^(NSArray *successful, BOOL *cancelFavicons) { - if (successful.count > 0) - [StoreCoordinator saveContext:moc andParent:YES]; - // we need to post a reset, since after deletion total unread count is wrong - [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil]; - } finally:^(BOOL successful) { - [moc.undoManager endUndoGrouping]; - if (successful) { - [StoreCoordinator saveContext:moc andParent:YES]; - [tree rearrangeObjects]; // rearrange, because no new items appread instead only icon attrib changed - } - }]; - }]; -} - #pragma mark - Import @@ -98,9 +85,9 @@ If user chooses to replace existing items, perform core data request to delete all feeds. @param document Used to count feed items that will be imported - @return @c NO if user clicks 'Cancel' button. @c YES otherwise. + @return @c -1: User clicked 'Cancel' button. @c 0: Append items. @c 1: Overwrite items. */ -+ (BOOL)askToAppendOrOverwriteAlert:(RSOPMLItem*)document inContext:(NSManagedObjectContext*)moc { ++ (NSInteger)askToAppendOrOverwriteAlert:(RSOPMLItem*)document inContext:(NSManagedObjectContext*)moc { NSUInteger count = [self recursiveNumberOfFeeds:document]; NSAlert *alert = [[NSAlert alloc] init]; alert.messageText = [NSString stringWithFormat:NSLocalizedString(@"Import of %lu feed items", nil), count]; @@ -109,42 +96,43 @@ [alert addButtonWithTitle:NSLocalizedString(@"Cancel", nil)]; alert.accessoryView = [self radioGroupCreate:@[NSLocalizedString(@"Append", nil), NSLocalizedString(@"Overwrite", nil)]]; - NSModalResponse code = [alert runModal]; - if (code == NSAlertSecondButtonReturn) { // cancel button - return NO; + + if ([alert runModal] == NSAlertFirstButtonReturn) { + return [self radioGroupSelection:alert.accessoryView]; } - if ([self radioGroupSelection:alert.accessoryView] == 1) { // overwrite selected - for (FeedGroup *g in [StoreCoordinator sortedListOfRootObjectsInContext:moc]) { - [moc deleteObject:g]; - } - } - return YES; + return -1; // cancel button } /** Perform import of @c FeedGroup items. - - @param block Called after import finished. Parameter @c added is the list of inserted @c Feed items. */ -+ (void)importFeedData:(NSURL*)fileURL inContext:(NSManagedObjectContext*)moc success:(nullable void(^)(NSArray *added))block { - NSData *data = [NSData dataWithContentsOfURL:fileURL]; - RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:@"opml-file-import"]; - RSOPMLParser *parser = [RSOPMLParser parserWithXMLData:xml]; - [parser parseAsync:^(RSOPMLItem * _Nullable doc, NSError * _Nullable error) { - if (error) { - [NSApp presentError:error]; - } else if ([self askToAppendOrOverwriteAlert:doc inContext:moc]) { - NSMutableArray *list = [NSMutableArray array]; - int32_t idx = 0; - if (moc.deletedObjects.count == 0) // if there are deleted objects, user choose to overwrite all items - idx = (int32_t)[StoreCoordinator numberRootItemsInContext:moc]; - - for (RSOPMLItem *item in doc.children) { - [self importFeed:item parent:nil index:idx inContext:moc appendToList:list]; - idx += 1; - } - if (block) block(list); ++ (void)importOPMLDocument:(RSOPMLItem*)doc inContext:(NSManagedObjectContext*)moc { + NSInteger select = [self askToAppendOrOverwriteAlert:doc inContext:moc]; + if (select < 0 || select > 1) // not a valid selection (or cancel button) + return; + + [moc.undoManager beginUndoGrouping]; + + int32_t idx = 0; + if (select == 1) { // overwrite selected + for (FeedGroup *fg in [StoreCoordinator sortedListOfRootObjectsInContext:moc]) { + [moc deleteObject:fg]; // Not a batch delete request to support undo } + [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:@(0)]; + } else { + idx = (int32_t)[StoreCoordinator numberRootItemsInContext:moc]; + } + + NSMutableArray *list = [NSMutableArray array]; + for (RSOPMLItem *item in doc.children) { + [self importFeed:item parent:nil index:idx inContext:moc appendToList:list]; + idx += 1; + } + // Persist state, because on crash we have at least inserted items (without articles & icons) + [StoreCoordinator saveContext:moc andParent:YES]; + [FeedDownload batchDownloadFeeds:list favicons:YES showErrorAlert:YES finally:^{ + [StoreCoordinator saveContext:moc andParent:YES]; + [moc.undoManager endUndoGrouping]; }]; } @@ -206,9 +194,9 @@ */ + (NSXMLDocument*)xmlDocumentForFeeds:(NSArray*)list hierarchical:(BOOL)flag { NSXMLElement *head = [NSXMLElement elementWithName:@"head"]; - [head addChild:[NSXMLElement elementWithName:@"title" stringValue:@"baRSS feeds"]]; - [head addChild:[NSXMLElement elementWithName:@"ownerName" stringValue:@"baRSS"]]; - [head addChild:[NSXMLElement elementWithName:@"dateCreated" stringValue:[self currentDayAsStringISO8601:YES]]]; + head.children = @[[NSXMLElement elementWithName:@"title" stringValue:@"baRSS feeds"], + [NSXMLElement elementWithName:@"ownerName" stringValue:@"baRSS"], + [NSXMLElement elementWithName:@"dateCreated" stringValue:[self currentDayAsStringISO8601:YES]] ]; NSXMLElement *body = [NSXMLElement elementWithName:@"body"]; for (FeedGroup *item in list) { @@ -216,9 +204,8 @@ } NSXMLElement *opml = [NSXMLElement elementWithName:@"opml"]; - [opml addAttribute:[NSXMLNode attributeWithName:@"version" stringValue:@"1.0"]]; - [opml addChild:head]; - [opml addChild:body]; + opml.attributes = @[[NSXMLNode attributeWithName:@"version" stringValue:@"1.0"]]; + opml.children = @[head, body]; NSXMLDocument *xml = [NSXMLDocument documentWithRootElement:opml]; xml.version = @"1.0"; diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeeds.m b/baRSS/Preferences/Feeds Tab/SettingsFeeds.m index 0cfeb96..5b4837d 100644 --- a/baRSS/Preferences/Feeds Tab/SettingsFeeds.m +++ b/baRSS/Preferences/Feeds Tab/SettingsFeeds.m @@ -27,10 +27,13 @@ #import "Feed+Ext.h" #import "FeedGroup+Ext.h" #import "OpmlExport.h" +#import "FeedDownload.h" @interface SettingsFeeds () @property (weak) IBOutlet NSOutlineView *outlineView; @property (weak) IBOutlet NSTreeController *dataStore; +@property (weak) IBOutlet NSProgressIndicator *spinner; +@property (weak) IBOutlet NSTextField *spinnerLabel; @property (strong) NSArray *currentlyDraggedNodes; @property (strong) NSUndoManager *undoManager; @@ -44,6 +47,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; - (void)viewDidLoad { [super viewDidLoad]; + [self activateSpinner:([FeedDownload isUpdating] ? -1 : 0)]; // start spinner if update is in progress when preferences open [self.outlineView registerForDraggedTypes:[NSArray arrayWithObject:dragNodeType]]; [self.dataStore setSortDescriptors:[NSArray arrayWithObject:[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]]]; @@ -53,9 +57,61 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; self.dataStore.managedObjectContext = [StoreCoordinator createChildContext]; self.dataStore.managedObjectContext.undoManager = self.undoManager; - self.dataStore.managedObjectContext.automaticallyMergesChangesFromParent = NO; + + // Register for notifications + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateIcon:) name:kNotificationFeedUpdated object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateIcon:) name:kNotificationFeedIconUpdated object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateInProgress:) name:kNotificationBackgroundUpdateInProgress object:nil]; } +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + + +#pragma mark - Notification callback methods + + +/// Callback method fired when feeds have been updated in the background. +- (void)updateIcon:(NSNotification*)notify { + NSManagedObjectID *oid = notify.object; + NSManagedObjectContext *moc = self.dataStore.managedObjectContext; + Feed *feed = [moc objectRegisteredForID:oid]; + if (feed) { + if (self.undoManager.groupingLevel == 0) // don't mess around if user is editing something + [moc refreshObject:feed mergeChanges:YES]; + [self.dataStore rearrangeObjects]; + } +} + +/// Callback method fired when background feed update begins and ends. +- (void)updateInProgress:(NSNotification*)notify { + [self activateSpinner:[notify.object integerValue]]; +} + +/// Start or stop activity spinner (will run on main thread). If @c c @c == @c 0 stop spinner. +- (void)activateSpinner:(NSInteger)c { + dispatch_async(dispatch_get_main_queue(), ^{ + if (c == 0) { + [self.spinner stopAnimation:nil]; + self.spinnerLabel.stringValue = @""; + } else { + [self.spinner startAnimation:nil]; + if (c < 0) { // unknown number of feeds + self.spinnerLabel.stringValue = NSLocalizedString(@"Updating feeds …", nil); + } else if (c == 1) { + self.spinnerLabel.stringValue = NSLocalizedString(@"Updating 1 feed …", nil); + } else { + self.spinnerLabel.stringValue = [NSString stringWithFormat:NSLocalizedString(@"Updating %lu feeds …", nil), c]; + } + } + }); +} + + +#pragma mark - Persist state + + /** Refresh current context from parent context and start new undo grouping. @note Should be balanced with @c endCoreDataChangeUndoChanges: @@ -89,10 +145,13 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; After the user did undo or redo we can't ensure integrity without doing some additional work. */ - (void)saveWithUnpredictableChange { - NSSet *arr = [self.dataStore.managedObjectContext.insertedObjects - filteredSetUsingPredicate:[NSPredicate predicateWithFormat:@"class == %@", [Feed class]]]; + // dont use unless you merge changes from main +// NSManagedObjectContext *moc = self.dataStore.managedObjectContext; +// NSPredicate *pred = [NSPredicate predicateWithFormat:@"class == %@", [FeedArticle class]]; +// NSInteger del = [[[moc.deletedObjects filteredSetUsingPredicate:pred] valueForKeyPath:@"@sum.unread"] integerValue]; +// NSInteger ins = [[[moc.insertedObjects filteredSetUsingPredicate:pred] valueForKeyPath:@"@sum.unread"] integerValue]; +// NSLog(@"%ld, %ld", del, ins); [StoreCoordinator saveContext:self.dataStore.managedObjectContext andParent:YES]; - [StoreCoordinator restoreFeedCountsAndIndexPaths:[arr valueForKeyPath:@"objectID"]]; // main context will not create undo group [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil]; [self.dataStore rearrangeObjects]; // update ordering } @@ -150,7 +209,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; if ([sender.menu popUpMenuPositioningItem:nil atLocation:NSMakePoint(0,sender.frame.size.height) inView:sender]) { NSInteger tag = sender.menu.highlightedItem.tag; if (tag == 101) { - [OpmlExport showImportDialog:self.view.window withTreeController:self.dataStore]; + [OpmlExport showImportDialog:self.view.window withContext:self.dataStore.managedObjectContext]; } else if (tag == 102) { [OpmlExport showExportDialog:self.view.window withContext:self.dataStore.managedObjectContext]; } @@ -323,8 +382,10 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; /// Returning @c NO will result in a Action-Not-Available-Buzzer sound - (BOOL)respondsToSelector:(SEL)aSelector { - if (aSelector == @selector(undo:)) return [self.undoManager canUndo] && self.undoManager.groupingLevel == 0; - if (aSelector == @selector(redo:)) return [self.undoManager canRedo] && self.undoManager.groupingLevel == 0; + if (aSelector == @selector(undo:)) + return [self.undoManager canUndo] && self.undoManager.groupingLevel == 0 && ![FeedDownload isUpdating]; + if (aSelector == @selector(redo:)) + return [self.undoManager canRedo] && self.undoManager.groupingLevel == 0 && ![FeedDownload isUpdating]; if (aSelector == @selector(copy:) || aSelector == @selector(enterPressed:)) { BOOL outlineHasFocus = [[self.view.window firstResponder] isKindOfClass:[NSOutlineView class]]; BOOL hasSelection = (self.dataStore.selectedNodes.count > 0); diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeeds.xib b/baRSS/Preferences/Feeds Tab/SettingsFeeds.xib index ae978f7..02c0285 100644 --- a/baRSS/Preferences/Feeds Tab/SettingsFeeds.xib +++ b/baRSS/Preferences/Feeds Tab/SettingsFeeds.xib @@ -10,6 +10,8 @@ + + @@ -182,20 +184,8 @@ CA - + + + + + + + + + + + + + + diff --git a/baRSS/Preferences/General Tab/SettingsGeneral.m b/baRSS/Preferences/General Tab/SettingsGeneral.m index 6775e2c..9973992 100644 --- a/baRSS/Preferences/General Tab/SettingsGeneral.m +++ b/baRSS/Preferences/General Tab/SettingsGeneral.m @@ -25,6 +25,7 @@ #import "BarMenu.h" #import "UserPrefs.h" #import "StoreCoordinator.h" +#import "Constants.h" #import @@ -60,8 +61,10 @@ } - (IBAction)fixCache:(NSButton *)sender { - [StoreCoordinator deleteUnreferencedFeeds]; - [StoreCoordinator restoreFeedCountsAndIndexPaths:nil]; + NSUInteger deleted = [StoreCoordinator deleteUnreferenced]; + [StoreCoordinator restoreFeedCountsAndIndexPaths]; + [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil]; + NSLog(@"Removed %lu unreferenced core data entries.", deleted); } - (IBAction)changeMenuBarIconSetting:(NSButton*)sender { diff --git a/baRSS/Status Bar Menu/BarMenu.h b/baRSS/Status Bar Menu/BarMenu.h index ce2debd..c0afecb 100644 --- a/baRSS/Status Bar Menu/BarMenu.h +++ b/baRSS/Status Bar Menu/BarMenu.h @@ -24,5 +24,4 @@ @interface BarMenu : NSObject - (void)updateBarIcon; -- (void)asyncReloadUnreadCountAndUpdateBarIcon; @end diff --git a/baRSS/Status Bar Menu/BarMenu.m b/baRSS/Status Bar Menu/BarMenu.m index b6e9a44..5c39955 100644 --- a/baRSS/Status Bar Menu/BarMenu.m +++ b/baRSS/Status Bar Menu/BarMenu.m @@ -54,13 +54,14 @@ // Unread counter self.unreadCountTotal = 0; [self updateBarIcon]; - [self asyncReloadUnreadCountAndUpdateBarIcon]; + [self asyncReloadUnreadCountAndUpdateBarIcon:nil]; // Register for notifications [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedUpdated:) name:kNotificationFeedUpdated object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedIconUpdated:) name:kNotificationFeedIconUpdated 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(asyncReloadUnreadCountAndUpdateBarIcon) name:kNotificationTotalUnreadCountReset object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(asyncReloadUnreadCountAndUpdateBarIcon:) name:kNotificationTotalUnreadCountReset object:nil]; return self; } @@ -70,12 +71,20 @@ #pragma mark - Update Menu Bar Icon -/// Regardless of current unread count, perform new core data fetch on total unread count and update icon. -- (void)asyncReloadUnreadCountAndUpdateBarIcon { - dispatch_async(dispatch_get_main_queue(), ^{ - self.unreadCountTotal = [StoreCoordinator unreadCountForIndexPathString:nil]; +/** + If notification has @c object use this object to set unread count directly. + If @c object is @c nil perform core data fetch on total unread count and update icon. + */ +- (void)asyncReloadUnreadCountAndUpdateBarIcon:(NSNotification*)notify { + if (notify.object) { // set unread count directly + self.unreadCountTotal = [[notify object] integerValue]; [self updateBarIcon]; - }); + } else { + 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. @@ -117,23 +126,38 @@ @param notify Notification object contains the unread count difference to the current count. May be negative. */ - (void)unreadCountChanged:(NSNotification*)notify { - self.unreadCountTotal += [[notify object] intValue]; + self.unreadCountTotal += [[notify object] integerValue]; [self updateBarIcon]; } /// Callback method fired when feeds have been updated in the background. - (void)feedUpdated:(NSNotification*)notify { + [self updateFeed:notify.object updateIconOnly:NO]; +} + +- (void)feedIconUpdated:(NSNotification*)notify { + [self updateFeed:notify.object updateIconOnly:YES]; +} + + +#pragma mark - Rebuild menu after background feed update + + +/** + Use this method to update a single menu item and all ancestors unread count. + If the menu isn't currently open, nothing will happen. + + @param oid @c NSManagedObjectID must be a @c Feed instance object id. + */ +- (void)updateFeed:(NSManagedObjectID*)oid updateIconOnly:(BOOL)flag { if (self.barItem.menu.numberOfItems > 0) { // update items only if menu is already open (e.g., during background update) NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; - for (NSManagedObjectID *oid in notify.object) { - Feed *feed = [moc objectWithID:oid]; - if (!feed) continue; + Feed *feed = [moc objectWithID:oid]; + if ([feed isKindOfClass:[Feed class]]) { NSMenu *menu = [self fixUnreadCountForSubmenus:feed]; - if (!menu || menu.numberOfItems > 0) - [self rebuiltFeedArticle:feed inMenu:menu]; // deepest menu level, feed items + if (!flag) [self rebuiltFeedArticle:feed inMenu:menu]; // deepest menu level, feed items } - [self.barItem.menu autoEnableMenuHeader:(self.unreadCountTotal > 0)]; // once per multi-feed update [moc reset]; } } @@ -145,18 +169,27 @@ */ - (nullable NSMenu*)fixUnreadCountForSubmenus:(Feed*)feed { NSMenu *menu = self.barItem.menu; + [menu autoEnableMenuHeader:(self.unreadCountTotal > 0)]; for (FeedGroup *parent in [feed.group allParents]) { - NSInteger offset = [menu feedDataOffset]; - NSMenuItem *item = [menu itemAtIndex:offset + parent.sortIndex]; + NSInteger itemIndex = [menu feedDataOffset] + parent.sortIndex; + NSMenuItem *item = [menu itemAtIndex:itemIndex]; NSInteger unread = [item setTitleAndUnreadCount:parent]; menu = item.submenu; + + if (parent == feed.group) { + // Always set icon. Will flip warning icon to default icon if article count changes. + item.image = [feed iconImage16]; + item.enabled = (feed.articles.count > 0); + return menu; + } + 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) + [menu autoEnableMenuHeader:(unread > 0)]; // of submenu but not articles menu (will be rebuild anyway) } - return menu; + return nil; } /** @@ -166,6 +199,8 @@ @param menu Deepest menu level which contains only feed items. */ - (void)rebuiltFeedArticle:(Feed*)feed inMenu:(NSMenu*)menu { + if (!menu || menu.numberOfItems == 0) // not opened yet + return; if (self.currentOpenMenu != menu) { // if the menu isn't open, re-create it dynamically instead menu.itemArray.firstObject.parentItem.submenu = [menu cleanInstanceCopy]; @@ -360,6 +395,7 @@ Called when user clicks on 'Update all feeds' in the main menu (only). */ - (void)updateAllFeeds:(NSMenuItem*)sender { + [self asyncReloadUnreadCountAndUpdateBarIcon:nil]; [FeedDownload forceUpdateAllFeeds]; } diff --git a/baRSS/StoreCoordinator.h b/baRSS/StoreCoordinator.h index 0f3469f..737ee64 100644 --- a/baRSS/StoreCoordinator.h +++ b/baRSS/StoreCoordinator.h @@ -37,8 +37,9 @@ + (NSInteger)numberRootItemsInContext:(NSManagedObjectContext*)moc; + (NSArray*)sortedListOfRootObjectsInContext:(NSManagedObjectContext*)moc; // Restore sound state -+ (void)deleteUnreferencedFeeds; -+ (void)restoreFeedCountsAndIndexPaths:(NSArray*)list; ++ (NSUInteger)deleteAllGroups; ++ (NSUInteger)deleteUnreferenced; ++ (void)restoreFeedCountsAndIndexPaths; + (NSArray*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc; + (NSArray*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc; @end diff --git a/baRSS/StoreCoordinator.m b/baRSS/StoreCoordinator.m index f92797d..479101f 100644 --- a/baRSS/StoreCoordinator.m +++ b/baRSS/StoreCoordinator.m @@ -24,8 +24,6 @@ #import "AppHook.h" #import "Feed+Ext.h" -#import - @implementation StoreCoordinator #pragma mark - Managing contexts @@ -40,7 +38,7 @@ NSManagedObjectContext *context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType]; [context setParentContext:[self getMainContext]]; context.undoManager = nil; - context.automaticallyMergesChangesFromParent = YES; + //context.automaticallyMergesChangesFromParent = YES; return context; } @@ -178,46 +176,64 @@ #pragma mark - Restore Sound State /** - Delete all @c Feed items where @c group @c = @c NULL. + Perform batch delete on entities of type @c entity where @c column @c IS @c NULL. If @c column is @c nil, delete all rows. */ -+ (void)deleteUnreferencedFeeds { - NSManagedObjectContext *moc = [self getMainContext]; - NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name]; - fr.predicate = [NSPredicate predicateWithFormat:@"group = NULL"]; ++ (NSUInteger)batchDelete:(NSEntityDescription*)entity nullAttribute:(NSString*)column inContext:(NSManagedObjectContext*)moc { + NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: entity.name]; + if (column && column.length > 0) { + // double nested string, otherwise column is not interpreted as such. + // using @count here to also find items where foreign key is set but referencing a non-existing object. + fr.predicate = [NSPredicate predicateWithFormat:[NSString stringWithFormat:@"count(%@) == 0", column]]; + } NSBatchDeleteRequest *bdr = [[NSBatchDeleteRequest alloc] initWithFetchRequest:fr]; + bdr.resultType = NSBatchDeleteResultTypeCount; NSError *err; - [moc executeRequest:bdr error:&err]; + NSBatchDeleteResult *lol = [moc executeRequest:bdr error:&err]; if (err) NSLog(@"%@", err); + return [lol.result unsignedIntegerValue]; } /** - Iterate over all @c Feed and re-calculate @c unreadCount, @c articleCount and @c indexPath. - Restore will happend on the main context. - - @param list A list of @c Feed objectIDs. Acts like a filter, if @c nil performs a fetch on all feed items. + Delete all @c FeedGroup items. */ -+ (void)restoreFeedCountsAndIndexPaths:(NSArray*)list { ++ (NSUInteger)deleteAllGroups { NSManagedObjectContext *moc = [self getMainContext]; - if (!list) { - NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name]; - [fr setResultType:NSManagedObjectIDResultType]; - list = [self fetchAllRows:fr inContext:moc]; + NSUInteger deleted = [self batchDelete:FeedGroup.entity nullAttribute:nil inContext:moc]; + [self saveContext:moc andParent:YES]; + return deleted; +} + +/** + Delete all @c Feed items where @c group @c = @c NULL and all @c FeedMeta, @c FeedIcon, @c FeedArticle where @c feed @c = @c NULL. + */ ++ (NSUInteger)deleteUnreferenced { + NSUInteger deleted = 0; + NSManagedObjectContext *moc = [self getMainContext]; + deleted += [self batchDelete:Feed.entity nullAttribute:@"group" inContext:moc]; + deleted += [self batchDelete:FeedMeta.entity nullAttribute:@"feed" inContext:moc]; + deleted += [self batchDelete:FeedIcon.entity nullAttribute:@"feed" inContext:moc]; + deleted += [self batchDelete:FeedArticle.entity nullAttribute:@"feed" inContext:moc]; + [self saveContext:moc andParent:YES]; + return deleted; +} + +/** + Iterate over all @c Feed and re-calculate @c unreadCount and @c indexPath. + */ ++ (void)restoreFeedCountsAndIndexPaths { + NSManagedObjectContext *moc = [self getMainContext]; + NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name]; + for (Feed *f in [self fetchAllRows:fr inContext:moc]) { + [f calculateAndSetUnreadCount]; + [f calculateAndSetIndexPathString]; } - [moc performBlock:^{ - for (NSManagedObjectID *moi in list) { - Feed *f = [moc objectWithID:moi]; - if ([f isKindOfClass:[Feed class]]) - [f resetArticleCountAndIndexPathString]; - } - [self saveContext:moc andParent:YES]; - }]; + [self saveContext:moc andParent:YES]; } /// @return All @c Feed items where @c articles.count @c == @c 0 + (NSArray*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc { NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name]; - // More accurate but with subquery on FeedArticle: "count(articles) == 0" - fr.predicate = [NSPredicate predicateWithFormat:@"articleCount == 0"]; + fr.predicate = [NSPredicate predicateWithFormat:@"articles.@count == 0"]; return [self fetchAllRows:fr inContext:moc]; }