FeedDownload refactoring: everything parallel!

This commit is contained in:
relikd
2019-01-25 02:12:15 +01:00
parent 8e90ca742f
commit d5354bb681
17 changed files with 427 additions and 330 deletions

View File

@@ -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

View File

@@ -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<FeedArticle*>*)sortedArticles;
@@ -37,5 +37,5 @@
- (int)markAllItemsUnread;
// Icon
- (NSImage*)iconImage16;
- (BOOL)setIcon:(NSImage*)img replaceExisting:(BOOL)overwrite;
- (BOOL)setIconImage:(NSImage*)img;
@end

View File

@@ -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;
}

View File

@@ -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));

View File

@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="14315.18" systemVersion="17G3025" minimumToolsVersion="Automatic" sourceLanguage="Objective-C" userDefinedModelVersionIdentifier="v1">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="14315.18" systemVersion="17G4015" minimumToolsVersion="Automatic" sourceLanguage="Objective-C" userDefinedModelVersionIdentifier="v1">
<entity name="Feed" representedClassName="Feed" syncable="YES" codeGenerationType="class">
<attribute name="articleCount" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<attribute name="indexPath" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="link" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="subtitle" optional="YES" attributeType="String" syncable="YES"/>
@@ -48,7 +47,7 @@
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="meta" inverseEntity="Feed" syncable="YES"/>
</entity>
<elements>
<element name="Feed" positionX="-278.84765625" positionY="-112.953125" width="128" height="195"/>
<element name="Feed" positionX="-278.84765625" positionY="-112.953125" width="128" height="180"/>
<element name="FeedArticle" positionX="-96.77734375" positionY="-113.83984375" width="128" height="195"/>
<element name="FeedGroup" positionX="-460.37890625" positionY="-111.62890625" width="130.52734375" height="150"/>
<element name="FeedIcon" positionX="-202.79296875" positionY="137.71875" width="128" height="75"/>

View File

@@ -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<RSHTMLMetadataFeedLink*> *list))askUser block:(nonnull void(^)(RSParsedFeed *parsed, NSError *error, NSHTTPURLResponse *response))block;
+ (void)autoDownloadAndParseURL:(NSString*)urlStr;
+ (void)batchDownloadRSSAndFavicons:(NSArray<Feed*> *)list showErrorAlert:(BOOL)flag rssFinished:(void(^)(NSArray<Feed*> *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<Feed*> *)list favicons:(BOOL)fav showErrorAlert:(BOOL)alert finally:(nullable os_block_t)block;
@end

View File

@@ -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<Feed*> *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<Feed*> *successful, NSArray<Feed*> *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<Feed*> *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<Feed*>*)changedFeeds {
[StoreCoordinator saveContext:moc andParent:YES];
if (changedFeeds && changedFeeds.count > 0) {
NSArray<NSManagedObjectID*> *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<RSHTMLMetadataFeedLink*> *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.
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 feed @c Feed on which the update is executed.
@param group Mutex to count completion of all downloads.
@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<Feed*>*)successful
failed:(nonnull NSMutableArray<Feed*>*)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<Feed *> *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
Start download of feed xml, then continue with favicon download (optional).
@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.
@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<Feed*> *)list
showErrorAlert:(BOOL)flag
rssFinished:(void(^)(NSArray<Feed*> *successful, BOOL * cancelFavicons))blockXml
finally:(void(^)(BOOL successful))blockFavicon
{
[self batchUpdateFeeds:list showErrorAlert:flag finally:^(NSArray<Feed*> *successful, NSArray<Feed*> *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<Feed*> *)list showErrorAlert:(BOOL)flag finally:(void(^)(NSArray<Feed*> *successful, NSArray<Feed*> *failed))block {
if (!list || list.count == 0) {
if (block) block(nil, nil);
return;
}
// else, process all feed items in a batch
NSMutableArray<Feed*> *successful = [NSMutableArray arrayWithCapacity:list.count];
NSMutableArray<Feed*> *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<Feed*> *)list replaceExisting:(BOOL)flag finally:(os_block_t)block {
+ (void)batchDownloadFeeds:(NSArray<Feed*> *)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), ^{

View File

@@ -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<RSHTMLMetadataFeedLink *> *list) {
return [self letUserChooseXmlUrlFromList:list];
} block:^(RSParsedFeed *result, NSError *error, NSHTTPURLResponse* response) {

View File

@@ -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

View File

@@ -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<Feed*> *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<Feed *> *added) {
[StoreCoordinator saveContext:moc andParent:YES];
[FeedDownload batchDownloadRSSAndFavicons:added showErrorAlert:YES rssFinished:^(NSArray<Feed *> *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<Feed*> *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<Feed*> *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];
+ (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;
for (RSOPMLItem *item in doc.children) {
[self importFeed:item parent:nil index:idx inContext:moc appendToList:list];
idx += 1;
}
if (block) block(list);
[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<Feed*> *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<FeedGroup*>*)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";

View File

@@ -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<NSTreeNode*> *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<Feed*> *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);

View File

@@ -10,6 +10,8 @@
<connections>
<outlet property="dataStore" destination="JPf-gH-wxm" id="9qy-D6-L4R"/>
<outlet property="outlineView" destination="wP9-Vd-f79" id="nKf-fc-7Np"/>
<outlet property="spinner" destination="fos-vP-s2s" id="zZp-Op-ftK"/>
<outlet property="spinnerLabel" destination="44U-lx-hnq" id="GGB-H5-7LV"/>
<outlet property="view" destination="zfc-Ie-Sdx" id="65R-bK-FDI"/>
</connections>
</customObject>
@@ -182,20 +184,8 @@ CA
<binding destination="JPf-gH-wxm" name="enabled" keyPath="canRemove" id="XYY-gx-tiN"/>
</connections>
</button>
<button toolTip="Add new line separator" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="kn9-pd-A47">
<rect key="frame" x="96" y="-1" width="25" height="23"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<buttonCell key="cell" type="smallSquare" title="---" alternateTitle="Add separator" bezelStyle="smallSquare" image="NSPathTemplate" alignment="center" lineBreakMode="truncatingTail" state="on" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="r9B-nl-XkX">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<action selector="addSeparator:" target="-2" id="dVQ-ge-moI"/>
<binding destination="JPf-gH-wxm" name="enabled" keyPath="canInsert" id="2aK-XU-RUD"/>
</connections>
</button>
<button toolTip="Add new grouping folder" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="jPg-sh-1Az">
<rect key="frame" x="72" y="-1" width="25" height="23"/>
<rect key="frame" x="64" y="-1" width="25" height="23"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<buttonCell key="cell" type="smallSquare" alternateTitle="Add group" bezelStyle="smallSquare" image="NSPathTemplate" imagePosition="overlaps" alignment="center" lineBreakMode="truncatingTail" state="on" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="rPk-c8-lMe">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
@@ -208,9 +198,21 @@ CA
<binding destination="JPf-gH-wxm" name="enabled" keyPath="canInsert" id="JCF-ey-YUL"/>
</connections>
</button>
<button toolTip="Add new line separator" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="kn9-pd-A47">
<rect key="frame" x="88" y="-1" width="25" height="23"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<buttonCell key="cell" type="smallSquare" title="---" alternateTitle="Add separator" bezelStyle="smallSquare" image="NSPathTemplate" alignment="center" lineBreakMode="truncatingTail" state="on" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="r9B-nl-XkX">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<action selector="addSeparator:" target="-2" id="dVQ-ge-moI"/>
<binding destination="JPf-gH-wxm" name="enabled" keyPath="canInsert" id="2aK-XU-RUD"/>
</connections>
</button>
<button toolTip="Import or Export data" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="6ul-3K-fOy">
<rect key="frame" x="295" y="-1" width="25" height="23"/>
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMaxY="YES"/>
<rect key="frame" x="128" y="-1" width="25" height="23"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<buttonCell key="cell" type="smallSquare" alternateTitle="Export" bezelStyle="smallSquare" image="NSShareTemplate" imagePosition="overlaps" alignment="center" lineBreakMode="truncatingTail" state="on" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="nrA-7c-1sL">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
@@ -219,6 +221,19 @@ CA
<action selector="shareMenu:" target="-2" id="JJq-7D-Bti"/>
</connections>
</button>
<progressIndicator wantsLayer="YES" horizontalHuggingPriority="750" verticalHuggingPriority="750" fixedFrame="YES" maxValue="100" displayedWhenStopped="NO" bezeled="NO" indeterminate="YES" controlSize="small" style="spinning" translatesAutoresizingMaskIntoConstraints="NO" id="fos-vP-s2s">
<rect key="frame" x="168" y="3" width="16" height="16"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
</progressIndicator>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="44U-lx-hnq">
<rect key="frame" x="190" y="4" width="112" height="14"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMaxY="YES"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="&lt;string&gt;" id="yyA-K6-M3v">
<font key="font" metaFont="smallSystem"/>
<color key="textColor" name="systemGrayColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
</subviews>
<point key="canvasLocation" x="27" y="882.5"/>
</customView>

View File

@@ -25,6 +25,7 @@
#import "BarMenu.h"
#import "UserPrefs.h"
#import "StoreCoordinator.h"
#import "Constants.h"
#import <ServiceManagement/ServiceManagement.h>
@@ -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 {

View File

@@ -24,5 +24,4 @@
@interface BarMenu : NSObject <NSMenuDelegate>
- (void)updateBarIcon;
- (void)asyncReloadUnreadCountAndUpdateBarIcon;
@end

View File

@@ -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];
}

View File

@@ -37,8 +37,9 @@
+ (NSInteger)numberRootItemsInContext:(NSManagedObjectContext*)moc;
+ (NSArray<FeedGroup*>*)sortedListOfRootObjectsInContext:(NSManagedObjectContext*)moc;
// Restore sound state
+ (void)deleteUnreferencedFeeds;
+ (void)restoreFeedCountsAndIndexPaths:(NSArray<NSManagedObjectID*>*)list;
+ (NSUInteger)deleteAllGroups;
+ (NSUInteger)deleteUnreferenced;
+ (void)restoreFeedCountsAndIndexPaths;
+ (NSArray<Feed*>*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc;
+ (NSArray<Feed*>*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc;
@end

View File

@@ -24,8 +24,6 @@
#import "AppHook.h"
#import "Feed+Ext.h"
#import <RSXML/RSXML.h>
@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<NSManagedObjectID*>*)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<Feed*>*)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];
}