FeedDownload refactoring: everything parallel!
This commit is contained in:
@@ -35,14 +35,9 @@ All in all, the software is in a usable state. The remaining features will be ad
|
|||||||
ToDo
|
ToDo
|
||||||
----
|
----
|
||||||
|
|
||||||
- [ ] Preferences
|
|
||||||
- [ ] ~~Choose status bar icon?~~
|
|
||||||
- [ ] Display license info (e.g., RSXML)
|
|
||||||
|
|
||||||
|
|
||||||
- [ ] Edit feed
|
- [ ] Edit feed
|
||||||
- [ ] Show statistics
|
- [ ] 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?
|
- [ ] Automatically choose best interval?
|
||||||
- [ ] Show time of next update
|
- [ ] Show time of next update
|
||||||
- [ ] Feeds with authentication
|
- [ ] Feeds with authentication
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
+ (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context;
|
+ (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context;
|
||||||
+ (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc;
|
+ (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc;
|
||||||
- (void)calculateAndSetIndexPathString;
|
- (void)calculateAndSetIndexPathString;
|
||||||
- (void)resetArticleCountAndIndexPathString;
|
- (void)calculateAndSetUnreadCount;
|
||||||
- (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag;
|
- (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag;
|
||||||
// Article properties
|
// Article properties
|
||||||
- (NSArray<FeedArticle*>*)sortedArticles;
|
- (NSArray<FeedArticle*>*)sortedArticles;
|
||||||
@@ -37,5 +37,5 @@
|
|||||||
- (int)markAllItemsUnread;
|
- (int)markAllItemsUnread;
|
||||||
// Icon
|
// Icon
|
||||||
- (NSImage*)iconImage16;
|
- (NSImage*)iconImage16;
|
||||||
- (BOOL)setIcon:(NSImage*)img replaceExisting:(BOOL)overwrite;
|
- (BOOL)setIconImage:(NSImage*)img;
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -57,15 +57,11 @@
|
|||||||
self.indexPath = pthStr;
|
self.indexPath = pthStr;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset attributes @c articleCount, @c unreadCount, and @c indexPath.
|
/// Reset attributes @c unreadCount by counting number of articles. @note Remember to update global unread count.
|
||||||
- (void)resetArticleCountAndIndexPathString {
|
- (void)calculateAndSetUnreadCount {
|
||||||
int16_t totalCount = (int16_t)self.articles.count;
|
int32_t unreadCount = (int32_t)[[self.articles valueForKeyPath:@"@sum.unread"] integerValue];
|
||||||
int16_t unreadCount = (int16_t)[[self.articles valueForKeyPath:@"@sum.unread"] integerValue];
|
|
||||||
if (self.articleCount != totalCount)
|
|
||||||
self.articleCount = totalCount;
|
|
||||||
if (self.unreadCount != unreadCount)
|
if (self.unreadCount != unreadCount)
|
||||||
self.unreadCount = unreadCount; // remember to update global total unread count
|
self.unreadCount = unreadCount;
|
||||||
[self calculateAndSetIndexPathString];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -89,12 +85,10 @@
|
|||||||
[self addMissingArticles:obj updateLinks:urls]; // will remove links in 'urls' that should be kept
|
[self addMissingArticles:obj updateLinks:urls]; // will remove links in 'urls' that should be kept
|
||||||
[self deleteArticlesWithLink:urls]; // remove old, outdated articles
|
[self deleteArticlesWithLink:urls]; // remove old, outdated articles
|
||||||
// Get new total article count and post unread-count-change notification
|
// 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) {
|
if (flag) {
|
||||||
NSNumber *cDiff = [NSNumber numberWithInteger:self.unreadCount - unreadBefore];
|
int32_t cDiff = self.unreadCount - unreadBefore;
|
||||||
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged object:cDiff];
|
if (cDiff != 0)
|
||||||
|
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged object:@(cDiff)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +231,7 @@
|
|||||||
fa.unread = !readFlag;
|
fa.unread = !readFlag;
|
||||||
}
|
}
|
||||||
int32_t oldCount = self.unreadCount;
|
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)
|
if (self.unreadCount != newCount)
|
||||||
self.unreadCount = newCount;
|
self.unreadCount = newCount;
|
||||||
return newCount - oldCount;
|
return newCount - oldCount;
|
||||||
@@ -258,7 +252,7 @@
|
|||||||
[img setSize:NSMakeSize(16, 16)];
|
[img setSize:NSMakeSize(16, 16)];
|
||||||
return img;
|
return img;
|
||||||
}
|
}
|
||||||
else if (self.articleCount == 0)
|
else if (self.articles.count == 0)
|
||||||
{
|
{
|
||||||
static NSImage *warningIcon;
|
static NSImage *warningIcon;
|
||||||
if (!warningIcon) {
|
if (!warningIcon) {
|
||||||
@@ -277,12 +271,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
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 {
|
- (BOOL)setIconImage:(NSImage*)img {
|
||||||
if (overwrite || !self.icon) { // write if forced or image empty
|
|
||||||
if (img && [img isValid]) {
|
if (img && [img isValid]) {
|
||||||
if (!self.icon)
|
if (!self.icon)
|
||||||
self.icon = [[FeedIcon alloc] initWithEntity:FeedIcon.entity insertIntoManagedObjectContext:self.managedObjectContext];
|
self.icon = [[FeedIcon alloc] initWithEntity:FeedIcon.entity insertIntoManagedObjectContext:self.managedObjectContext];
|
||||||
@@ -293,7 +286,6 @@
|
|||||||
self.icon = nil;
|
self.icon = nil;
|
||||||
return YES;
|
return YES;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return NO;
|
return NO;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,11 +29,42 @@
|
|||||||
// TODO: List of hidden preferences for readme
|
// TODO: List of hidden preferences for readme
|
||||||
// TODO: Do we need to search for favicon in places other than '../favicon.ico'?
|
// 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";
|
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";
|
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";
|
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";
|
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));
|
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));}
|
//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));
|
#define benchmark(desc,block) printf(desc": %llu ns\n", dispatch_benchmark(1, block));
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?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">
|
<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="indexPath" optional="YES" attributeType="String" syncable="YES"/>
|
||||||
<attribute name="link" optional="YES" attributeType="String" syncable="YES"/>
|
<attribute name="link" optional="YES" attributeType="String" syncable="YES"/>
|
||||||
<attribute name="subtitle" 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"/>
|
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="meta" inverseEntity="Feed" syncable="YES"/>
|
||||||
</entity>
|
</entity>
|
||||||
<elements>
|
<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="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="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"/>
|
<element name="FeedIcon" positionX="-202.79296875" positionY="137.71875" width="128" height="75"/>
|
||||||
|
|||||||
@@ -26,6 +26,10 @@
|
|||||||
@class Feed;
|
@class Feed;
|
||||||
|
|
||||||
@interface FeedDownload : NSObject
|
@interface FeedDownload : NSObject
|
||||||
|
@property (class, readonly) BOOL allowNetworkConnection;
|
||||||
|
@property (class, readonly) BOOL isUpdating;
|
||||||
|
@property (class, setter=setPaused:) BOOL isPaused;
|
||||||
|
|
||||||
// Register for network change notifications
|
// Register for network change notifications
|
||||||
+ (void)registerNetworkChangeNotification;
|
+ (void)registerNetworkChangeNotification;
|
||||||
+ (void)unregisterNetworkChangeNotification;
|
+ (void)unregisterNetworkChangeNotification;
|
||||||
@@ -35,12 +39,8 @@
|
|||||||
// Downloading
|
// Downloading
|
||||||
+ (void)newFeed:(NSString *)urlStr askUser:(nonnull NSString*(^)(NSArray<RSHTMLMetadataFeedLink*> *list))askUser block:(nonnull void(^)(RSParsedFeed *parsed, NSError *error, NSHTTPURLResponse *response))block;
|
+ (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)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;
|
+ (void)downloadFavicon:(NSString*)urlStr finished:(void(^)(NSImage * _Nullable img))block;
|
||||||
// User interaction
|
+ (void)batchDownloadFeeds:(NSArray<Feed*> *)list favicons:(BOOL)fav showErrorAlert:(BOOL)alert finally:(nullable os_block_t)block;
|
||||||
+ (BOOL)allowNetworkConnection;
|
|
||||||
+ (BOOL)isPaused;
|
|
||||||
+ (void)setPaused:(BOOL)flag;
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,23 +31,24 @@
|
|||||||
|
|
||||||
static SCNetworkReachabilityRef _reachability = NULL;
|
static SCNetworkReachabilityRef _reachability = NULL;
|
||||||
static BOOL _isReachable = NO;
|
static BOOL _isReachable = NO;
|
||||||
|
static BOOL _isUpdating = NO;
|
||||||
static BOOL _updatePaused = NO;
|
static BOOL _updatePaused = NO;
|
||||||
static BOOL _nextUpdateIsForced = NO;
|
static BOOL _nextUpdateIsForced = NO;
|
||||||
|
|
||||||
|
|
||||||
@implementation FeedDownload
|
@implementation FeedDownload
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - User Interaction -
|
#pragma mark - User Interaction -
|
||||||
|
|
||||||
/// @return @c YES if current network state is reachable and updates are not paused by user.
|
/// @return @c YES if current network state is reachable and updates are not paused by user.
|
||||||
+ (BOOL)allowNetworkConnection {
|
+ (BOOL)allowNetworkConnection { return (_isReachable && !_updatePaused); }
|
||||||
return (_isReachable && !_updatePaused);
|
|
||||||
}
|
/// @return @c YES if batch update is running
|
||||||
|
+ (BOOL)isUpdating { return _isUpdating; }
|
||||||
|
|
||||||
/// @return @c YES if update is paused by user.
|
/// @return @c YES if update is paused by user.
|
||||||
+ (BOOL)isPaused {
|
+ (BOOL)isPaused { return _updatePaused; }
|
||||||
return _updatePaused;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set paused flag and cancel timer regardless of network connectivity.
|
/// Set paused flag and cancel timer regardless of network connectivity.
|
||||||
+ (void)setPaused:(BOOL)flag {
|
+ (void)setPaused:(BOOL)flag {
|
||||||
@@ -94,7 +95,7 @@ static BOOL _nextUpdateIsForced = NO;
|
|||||||
if (![self allowNetworkConnection]) // timer will restart once connection exists
|
if (![self allowNetworkConnection]) // timer will restart once connection exists
|
||||||
return;
|
return;
|
||||||
_nextUpdateIsForced = YES;
|
_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];
|
NSArray<Feed*> *list = [StoreCoordinator getListOfFeedsThatNeedUpdate:updateAll inContext:moc];
|
||||||
//NSAssert(list.count > 0, @"ERROR: Something went wrong, timer fired too early.");
|
//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 batchDownloadFeeds:list favicons:updateAll showErrorAlert:NO finally:^{
|
||||||
[self saveContext:moc andPostChanges:successful];
|
[StoreCoordinator saveContext:moc andParent:YES]; // save parents too ...
|
||||||
[moc reset];
|
[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
|
[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 -
|
#pragma mark - Request Generator -
|
||||||
|
|
||||||
@@ -179,20 +162,20 @@ static BOOL _nextUpdateIsForced = NO;
|
|||||||
req.cachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
|
req.cachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
|
||||||
req.HTTPShouldHandleCookies = NO;
|
req.HTTPShouldHandleCookies = NO;
|
||||||
// req.timeoutInterval = 30;
|
// 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 req;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @return New request with etag and modified headers set.
|
/// @return New request with etag and modified headers set (or not, if @c flag @c == @c YES ).
|
||||||
+ (NSURLRequest*)newRequest:(FeedMeta*)meta {
|
+ (NSURLRequest*)newRequest:(FeedMeta*)meta ignoreCache:(BOOL)flag {
|
||||||
NSMutableURLRequest *req = [self newRequestURL:meta.url];
|
NSMutableURLRequest *req = [self newRequestURL:meta.url];
|
||||||
|
if (!flag) {
|
||||||
NSString* etag = [meta.etag stringByReplacingOccurrencesOfString:@"-gzip" withString:@""];
|
NSString* etag = [meta.etag stringByReplacingOccurrencesOfString:@"-gzip" withString:@""];
|
||||||
if (meta.modified.length > 0)
|
if (meta.modified.length > 0)
|
||||||
[req setValue:meta.modified forHTTPHeaderField:@"If-Modified-Since"];
|
[req setValue:meta.modified forHTTPHeaderField:@"If-Modified-Since"];
|
||||||
if (etag.length > 0)
|
if (etag.length > 0)
|
||||||
[req setValue:etag forHTTPHeaderField:@"If-None-Match"]; // ETag
|
[req setValue:etag forHTTPHeaderField:@"If-None-Match"]; // ETag
|
||||||
if (!_nextUpdateIsForced) // any FeedMeta-request that is not forced, is a background update
|
}
|
||||||
|
if (!_nextUpdateIsForced) // any request that is not forced, is a background update
|
||||||
req.networkServiceType = NSURLNetworkServiceTypeBackground;
|
req.networkServiceType = NSURLNetworkServiceTypeBackground;
|
||||||
return req;
|
return req;
|
||||||
}
|
}
|
||||||
@@ -246,7 +229,7 @@ static BOOL _nextUpdateIsForced = NO;
|
|||||||
@note @c askUser will not be called if url is XML already.
|
@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 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).
|
@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 {
|
+ (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 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 block Parameter @c success is only @c YES if download was successful or if status code is 304 (not modified).
|
||||||
@param failed Empty, mutable list that will be returned in @c batchUpdateFeeds:finally:showErrorAlert: finally block
|
|
||||||
*/
|
*/
|
||||||
+ (void)downloadFeed:(Feed*)feed group:(dispatch_group_t)group
|
+ (void)backgroundUpdateFeed:(Feed*)feed showErrorAlert:(BOOL)alert finally:(nullable void(^)(BOOL success))block {
|
||||||
errorAlert:(BOOL)alert
|
|
||||||
successful:(nonnull NSMutableArray<Feed*>*)successful
|
|
||||||
failed:(nonnull NSMutableArray<Feed*>*)failed
|
|
||||||
{
|
|
||||||
if (![self allowNetworkConnection]) {
|
if (![self allowNetworkConnection]) {
|
||||||
[failed addObject:feed];
|
if (block) block(NO);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dispatch_group_enter(group);
|
NSManagedObjectID *oid = feed.objectID;
|
||||||
[self parseFeedRequest:[self newRequest:feed.meta] xmlBlock:nil feedBlock:^(RSParsedFeed *rss, NSError *error, NSHTTPURLResponse *response) {
|
NSManagedObjectContext *moc = feed.managedObjectContext;
|
||||||
if (!feed.isDeleted) {
|
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 (error) {
|
||||||
if (alert) {
|
if (alert) {
|
||||||
NSAlert *alertPopup = [NSAlert alertWithError:error];
|
NSAlert *alertPopup = [NSAlert alertWithError:error];
|
||||||
alertPopup.informativeText = [NSString stringWithFormat:@"Error loading source: %@", response.URL.absoluteString];
|
alertPopup.informativeText = [NSString stringWithFormat:@"Error loading source: %@", reqURL];
|
||||||
[alertPopup runModal];
|
[alertPopup runModal];
|
||||||
}
|
}
|
||||||
[feed.meta setErrorAndPostponeSchedule];
|
// TODO: don't increase error count on forced update
|
||||||
[failed addObject:feed];
|
[f.meta setErrorAndPostponeSchedule];
|
||||||
} else {
|
} else {
|
||||||
[feed.meta setSucessfulWithResponse:response];
|
success = YES;
|
||||||
if (rss) [feed updateWithRSS:rss postUnreadCountChange:YES];
|
[f.meta setSucessfulWithResponse:response];
|
||||||
[successful addObject:feed]; // will be added even if statusCode == 304 (rss == nil)
|
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];
|
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
||||||
Feed *f = [Feed appendToRootWithDefaultIntervalInContext:moc];
|
Feed *f = [Feed appendToRootWithDefaultIntervalInContext:moc];
|
||||||
f.meta.url = url;
|
f.meta.url = url;
|
||||||
[self batchDownloadRSSAndFavicons:@[f] showErrorAlert:YES rssFinished:^(NSArray<Feed *> *successful, BOOL *cancelFavicons) {
|
[self backgroundUpdateBoth:f favicon:YES alert:YES finally:^(BOOL successful){
|
||||||
if (successful.count == 0) {
|
if (!successful) {
|
||||||
*cancelFavicons = YES;
|
[moc deleteObject:f.group];
|
||||||
} else {
|
|
||||||
[self saveContext:moc andPostChanges:successful];
|
|
||||||
}
|
}
|
||||||
} finally:^(BOOL successful) {
|
|
||||||
if (successful) {
|
|
||||||
[StoreCoordinator saveContext:moc andParent:YES];
|
[StoreCoordinator saveContext:moc andParent:YES];
|
||||||
} else {
|
|
||||||
[moc rollback];
|
|
||||||
}
|
|
||||||
[moc reset];
|
[moc reset];
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Perform a download /update request for the feed data and download missing favicons.
|
Start download of feed xml, then continue with favicon download (optional).
|
||||||
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 fav If @c YES continue with favicon download after xml download finished.
|
||||||
@param flag If @c YES display Error Popup to user.
|
@param alert If @c YES display Error Popup to user.
|
||||||
@param blockXml Called after XML is downloaded and parsed.
|
@param block Parameter @c success is @c YES if xml download succeeded (regardless of favicon result).
|
||||||
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.
|
|
||||||
*/
|
*/
|
||||||
+ (void)batchDownloadRSSAndFavicons:(NSArray<Feed*> *)list
|
+ (void)backgroundUpdateBoth:(Feed*)feed favicon:(BOOL)fav alert:(BOOL)alert finally:(nullable void(^)(BOOL success))block {
|
||||||
showErrorAlert:(BOOL)flag
|
[self backgroundUpdateFeed:feed showErrorAlert:alert finally:^(BOOL success) {
|
||||||
rssFinished:(void(^)(NSArray<Feed*> *successful, BOOL * cancelFavicons))blockXml
|
if (fav && success) {
|
||||||
finally:(void(^)(BOOL successful))blockFavicon
|
[self backgroundUpdateFavicon:feed replaceExisting:NO finally:^{
|
||||||
{
|
if (block) block(YES);
|
||||||
[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);
|
|
||||||
}];
|
}];
|
||||||
|
} 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 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 fav If @c YES continue with favicon download after xml download finished.
|
||||||
@param block Called after all downloads finished @b OR if list is empty (in that case both parameters are @c nil ).
|
@param alert If @c YES display Error Popup to user.
|
||||||
*/
|
|
||||||
+ (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 block Called after all downloads finished.
|
@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();
|
dispatch_group_t group = dispatch_group_create();
|
||||||
for (Feed *f in list) {
|
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);
|
dispatch_group_enter(group);
|
||||||
[self downloadFavicon:faviconURL finished:^(NSImage *img) {
|
[self backgroundUpdateBoth:f favicon:fav alert:alert finally:^(BOOL success){
|
||||||
Feed *feed = [moc objectWithID:oid]; // should also work if context was reset
|
|
||||||
[feed setIcon:img replaceExisting:flag];
|
|
||||||
dispatch_group_leave(group);
|
dispatch_group_leave(group);
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
|
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
|
||||||
if (block) block();
|
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.
|
/// 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 {
|
+ (void)downloadFavicon:(NSString*)urlStr finished:(void(^)(NSImage * _Nullable img))block {
|
||||||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
||||||
|
|||||||
@@ -126,7 +126,7 @@
|
|||||||
if (self.didDownloadFeed) {
|
if (self.didDownloadFeed) {
|
||||||
[meta setEtag:self.httpEtag modified:self.httpDate];
|
[meta setEtag:self.httpEtag modified:self.httpDate];
|
||||||
[feed updateWithRSS:self.feedResult postUnreadCountChange:YES];
|
[feed updateWithRSS:self.feedResult postUnreadCountChange:YES];
|
||||||
[feed setIcon:self.favicon replaceExisting:YES];
|
[feed setIconImage:self.favicon];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +161,6 @@
|
|||||||
if (self.modalSheet.didCloseAndCancel)
|
if (self.modalSheet.didCloseAndCancel)
|
||||||
return;
|
return;
|
||||||
[self preDownload];
|
[self preDownload];
|
||||||
// TODO: parse webpage to find feed links instead (automatic link detection)
|
|
||||||
[FeedDownload newFeed:self.previousURL askUser:^NSString *(NSArray<RSHTMLMetadataFeedLink *> *list) {
|
[FeedDownload newFeed:self.previousURL askUser:^NSString *(NSArray<RSHTMLMetadataFeedLink *> *list) {
|
||||||
return [self letUserChooseXmlUrlFromList:list];
|
return [self letUserChooseXmlUrlFromList:list];
|
||||||
} block:^(RSParsedFeed *result, NSError *error, NSHTTPURLResponse* response) {
|
} block:^(RSParsedFeed *result, NSError *error, NSHTTPURLResponse* response) {
|
||||||
|
|||||||
@@ -26,6 +26,6 @@
|
|||||||
@class Feed;
|
@class Feed;
|
||||||
|
|
||||||
@interface OpmlExport : NSObject
|
@interface OpmlExport : NSObject
|
||||||
+ (void)showImportDialog:(NSWindow*)window withTreeController:(NSTreeController*)tree;
|
+ (void)showImportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc;
|
||||||
+ (void)showExportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc;
|
+ (void)showExportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc;
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -31,13 +31,22 @@
|
|||||||
|
|
||||||
#pragma mark - Open & Save Panel
|
#pragma mark - Open & Save Panel
|
||||||
|
|
||||||
/// Display Open File Panel to select @c .opml file.
|
/// 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 success:(nullable void(^)(NSArray<Feed*> *added))block {
|
+ (void)showImportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc {
|
||||||
NSOpenPanel *op = [NSOpenPanel openPanel];
|
NSOpenPanel *op = [NSOpenPanel openPanel];
|
||||||
op.allowedFileTypes = @[@"opml"];
|
op.allowedFileTypes = @[@"opml"];
|
||||||
[op beginSheetModalForWindow:window completionHandler:^(NSModalResponse result) {
|
[op beginSheetModalForWindow:window completionHandler:^(NSModalResponse result) {
|
||||||
if (result == NSModalResponseOK) {
|
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
|
#pragma mark - Import
|
||||||
|
|
||||||
@@ -98,9 +85,9 @@
|
|||||||
If user chooses to replace existing items, perform core data request to delete all feeds.
|
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
|
@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];
|
NSUInteger count = [self recursiveNumberOfFeeds:document];
|
||||||
NSAlert *alert = [[NSAlert alloc] init];
|
NSAlert *alert = [[NSAlert alloc] init];
|
||||||
alert.messageText = [NSString stringWithFormat:NSLocalizedString(@"Import of %lu feed items", nil), count];
|
alert.messageText = [NSString stringWithFormat:NSLocalizedString(@"Import of %lu feed items", nil), count];
|
||||||
@@ -109,42 +96,43 @@
|
|||||||
[alert addButtonWithTitle:NSLocalizedString(@"Cancel", nil)];
|
[alert addButtonWithTitle:NSLocalizedString(@"Cancel", nil)];
|
||||||
alert.accessoryView = [self radioGroupCreate:@[NSLocalizedString(@"Append", nil),
|
alert.accessoryView = [self radioGroupCreate:@[NSLocalizedString(@"Append", nil),
|
||||||
NSLocalizedString(@"Overwrite", nil)]];
|
NSLocalizedString(@"Overwrite", nil)]];
|
||||||
NSModalResponse code = [alert runModal];
|
|
||||||
if (code == NSAlertSecondButtonReturn) { // cancel button
|
if ([alert runModal] == NSAlertFirstButtonReturn) {
|
||||||
return NO;
|
return [self radioGroupSelection:alert.accessoryView];
|
||||||
}
|
}
|
||||||
if ([self radioGroupSelection:alert.accessoryView] == 1) { // overwrite selected
|
return -1; // cancel button
|
||||||
for (FeedGroup *g in [StoreCoordinator sortedListOfRootObjectsInContext:moc]) {
|
|
||||||
[moc deleteObject:g];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return YES;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Perform import of @c FeedGroup items.
|
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 {
|
+ (void)importOPMLDocument:(RSOPMLItem*)doc inContext:(NSManagedObjectContext*)moc {
|
||||||
NSData *data = [NSData dataWithContentsOfURL:fileURL];
|
NSInteger select = [self askToAppendOrOverwriteAlert:doc inContext:moc];
|
||||||
RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:@"opml-file-import"];
|
if (select < 0 || select > 1) // not a valid selection (or cancel button)
|
||||||
RSOPMLParser *parser = [RSOPMLParser parserWithXMLData:xml];
|
return;
|
||||||
[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];
|
|
||||||
|
|
||||||
|
[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) {
|
for (RSOPMLItem *item in doc.children) {
|
||||||
[self importFeed:item parent:nil index:idx inContext:moc appendToList:list];
|
[self importFeed:item parent:nil index:idx inContext:moc appendToList:list];
|
||||||
idx += 1;
|
idx += 1;
|
||||||
}
|
}
|
||||||
if (block) block(list);
|
// 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 {
|
+ (NSXMLDocument*)xmlDocumentForFeeds:(NSArray<FeedGroup*>*)list hierarchical:(BOOL)flag {
|
||||||
NSXMLElement *head = [NSXMLElement elementWithName:@"head"];
|
NSXMLElement *head = [NSXMLElement elementWithName:@"head"];
|
||||||
[head addChild:[NSXMLElement elementWithName:@"title" stringValue:@"baRSS feeds"]];
|
head.children = @[[NSXMLElement elementWithName:@"title" stringValue:@"baRSS feeds"],
|
||||||
[head addChild:[NSXMLElement elementWithName:@"ownerName" stringValue:@"baRSS"]];
|
[NSXMLElement elementWithName:@"ownerName" stringValue:@"baRSS"],
|
||||||
[head addChild:[NSXMLElement elementWithName:@"dateCreated" stringValue:[self currentDayAsStringISO8601:YES]]];
|
[NSXMLElement elementWithName:@"dateCreated" stringValue:[self currentDayAsStringISO8601:YES]] ];
|
||||||
|
|
||||||
NSXMLElement *body = [NSXMLElement elementWithName:@"body"];
|
NSXMLElement *body = [NSXMLElement elementWithName:@"body"];
|
||||||
for (FeedGroup *item in list) {
|
for (FeedGroup *item in list) {
|
||||||
@@ -216,9 +204,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
NSXMLElement *opml = [NSXMLElement elementWithName:@"opml"];
|
NSXMLElement *opml = [NSXMLElement elementWithName:@"opml"];
|
||||||
[opml addAttribute:[NSXMLNode attributeWithName:@"version" stringValue:@"1.0"]];
|
opml.attributes = @[[NSXMLNode attributeWithName:@"version" stringValue:@"1.0"]];
|
||||||
[opml addChild:head];
|
opml.children = @[head, body];
|
||||||
[opml addChild:body];
|
|
||||||
|
|
||||||
NSXMLDocument *xml = [NSXMLDocument documentWithRootElement:opml];
|
NSXMLDocument *xml = [NSXMLDocument documentWithRootElement:opml];
|
||||||
xml.version = @"1.0";
|
xml.version = @"1.0";
|
||||||
|
|||||||
@@ -27,10 +27,13 @@
|
|||||||
#import "Feed+Ext.h"
|
#import "Feed+Ext.h"
|
||||||
#import "FeedGroup+Ext.h"
|
#import "FeedGroup+Ext.h"
|
||||||
#import "OpmlExport.h"
|
#import "OpmlExport.h"
|
||||||
|
#import "FeedDownload.h"
|
||||||
|
|
||||||
@interface SettingsFeeds ()
|
@interface SettingsFeeds ()
|
||||||
@property (weak) IBOutlet NSOutlineView *outlineView;
|
@property (weak) IBOutlet NSOutlineView *outlineView;
|
||||||
@property (weak) IBOutlet NSTreeController *dataStore;
|
@property (weak) IBOutlet NSTreeController *dataStore;
|
||||||
|
@property (weak) IBOutlet NSProgressIndicator *spinner;
|
||||||
|
@property (weak) IBOutlet NSTextField *spinnerLabel;
|
||||||
|
|
||||||
@property (strong) NSArray<NSTreeNode*> *currentlyDraggedNodes;
|
@property (strong) NSArray<NSTreeNode*> *currentlyDraggedNodes;
|
||||||
@property (strong) NSUndoManager *undoManager;
|
@property (strong) NSUndoManager *undoManager;
|
||||||
@@ -44,6 +47,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
|
|
||||||
- (void)viewDidLoad {
|
- (void)viewDidLoad {
|
||||||
[super 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.outlineView registerForDraggedTypes:[NSArray arrayWithObject:dragNodeType]];
|
||||||
[self.dataStore setSortDescriptors:[NSArray arrayWithObject:[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]]];
|
[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 = [StoreCoordinator createChildContext];
|
||||||
self.dataStore.managedObjectContext.undoManager = self.undoManager;
|
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.
|
Refresh current context from parent context and start new undo grouping.
|
||||||
@note Should be balanced with @c endCoreDataChangeUndoChanges:
|
@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.
|
After the user did undo or redo we can't ensure integrity without doing some additional work.
|
||||||
*/
|
*/
|
||||||
- (void)saveWithUnpredictableChange {
|
- (void)saveWithUnpredictableChange {
|
||||||
NSSet<Feed*> *arr = [self.dataStore.managedObjectContext.insertedObjects
|
// dont use unless you merge changes from main
|
||||||
filteredSetUsingPredicate:[NSPredicate predicateWithFormat:@"class == %@", [Feed class]]];
|
// 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 saveContext:self.dataStore.managedObjectContext andParent:YES];
|
||||||
[StoreCoordinator restoreFeedCountsAndIndexPaths:[arr valueForKeyPath:@"objectID"]]; // main context will not create undo group
|
|
||||||
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
|
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
|
||||||
[self.dataStore rearrangeObjects]; // update ordering
|
[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]) {
|
if ([sender.menu popUpMenuPositioningItem:nil atLocation:NSMakePoint(0,sender.frame.size.height) inView:sender]) {
|
||||||
NSInteger tag = sender.menu.highlightedItem.tag;
|
NSInteger tag = sender.menu.highlightedItem.tag;
|
||||||
if (tag == 101) {
|
if (tag == 101) {
|
||||||
[OpmlExport showImportDialog:self.view.window withTreeController:self.dataStore];
|
[OpmlExport showImportDialog:self.view.window withContext:self.dataStore.managedObjectContext];
|
||||||
} else if (tag == 102) {
|
} else if (tag == 102) {
|
||||||
[OpmlExport showExportDialog:self.view.window withContext:self.dataStore.managedObjectContext];
|
[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
|
/// Returning @c NO will result in a Action-Not-Available-Buzzer sound
|
||||||
- (BOOL)respondsToSelector:(SEL)aSelector {
|
- (BOOL)respondsToSelector:(SEL)aSelector {
|
||||||
if (aSelector == @selector(undo:)) return [self.undoManager canUndo] && self.undoManager.groupingLevel == 0;
|
if (aSelector == @selector(undo:))
|
||||||
if (aSelector == @selector(redo:)) return [self.undoManager canRedo] && self.undoManager.groupingLevel == 0;
|
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:)) {
|
if (aSelector == @selector(copy:) || aSelector == @selector(enterPressed:)) {
|
||||||
BOOL outlineHasFocus = [[self.view.window firstResponder] isKindOfClass:[NSOutlineView class]];
|
BOOL outlineHasFocus = [[self.view.window firstResponder] isKindOfClass:[NSOutlineView class]];
|
||||||
BOOL hasSelection = (self.dataStore.selectedNodes.count > 0);
|
BOOL hasSelection = (self.dataStore.selectedNodes.count > 0);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
<connections>
|
<connections>
|
||||||
<outlet property="dataStore" destination="JPf-gH-wxm" id="9qy-D6-L4R"/>
|
<outlet property="dataStore" destination="JPf-gH-wxm" id="9qy-D6-L4R"/>
|
||||||
<outlet property="outlineView" destination="wP9-Vd-f79" id="nKf-fc-7Np"/>
|
<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"/>
|
<outlet property="view" destination="zfc-Ie-Sdx" id="65R-bK-FDI"/>
|
||||||
</connections>
|
</connections>
|
||||||
</customObject>
|
</customObject>
|
||||||
@@ -182,20 +184,8 @@ CA
|
|||||||
<binding destination="JPf-gH-wxm" name="enabled" keyPath="canRemove" id="XYY-gx-tiN"/>
|
<binding destination="JPf-gH-wxm" name="enabled" keyPath="canRemove" id="XYY-gx-tiN"/>
|
||||||
</connections>
|
</connections>
|
||||||
</button>
|
</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">
|
<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"/>
|
<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">
|
<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"/>
|
<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"/>
|
<binding destination="JPf-gH-wxm" name="enabled" keyPath="canInsert" id="JCF-ey-YUL"/>
|
||||||
</connections>
|
</connections>
|
||||||
</button>
|
</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">
|
<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"/>
|
<rect key="frame" x="128" y="-1" width="25" height="23"/>
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMaxY="YES"/>
|
<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">
|
<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"/>
|
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||||
<font key="font" metaFont="system"/>
|
<font key="font" metaFont="system"/>
|
||||||
@@ -219,6 +221,19 @@ CA
|
|||||||
<action selector="shareMenu:" target="-2" id="JJq-7D-Bti"/>
|
<action selector="shareMenu:" target="-2" id="JJq-7D-Bti"/>
|
||||||
</connections>
|
</connections>
|
||||||
</button>
|
</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="<string>" 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>
|
</subviews>
|
||||||
<point key="canvasLocation" x="27" y="882.5"/>
|
<point key="canvasLocation" x="27" y="882.5"/>
|
||||||
</customView>
|
</customView>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
#import "BarMenu.h"
|
#import "BarMenu.h"
|
||||||
#import "UserPrefs.h"
|
#import "UserPrefs.h"
|
||||||
#import "StoreCoordinator.h"
|
#import "StoreCoordinator.h"
|
||||||
|
#import "Constants.h"
|
||||||
|
|
||||||
#import <ServiceManagement/ServiceManagement.h>
|
#import <ServiceManagement/ServiceManagement.h>
|
||||||
|
|
||||||
@@ -60,8 +61,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
- (IBAction)fixCache:(NSButton *)sender {
|
- (IBAction)fixCache:(NSButton *)sender {
|
||||||
[StoreCoordinator deleteUnreferencedFeeds];
|
NSUInteger deleted = [StoreCoordinator deleteUnreferenced];
|
||||||
[StoreCoordinator restoreFeedCountsAndIndexPaths:nil];
|
[StoreCoordinator restoreFeedCountsAndIndexPaths];
|
||||||
|
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
|
||||||
|
NSLog(@"Removed %lu unreferenced core data entries.", deleted);
|
||||||
}
|
}
|
||||||
|
|
||||||
- (IBAction)changeMenuBarIconSetting:(NSButton*)sender {
|
- (IBAction)changeMenuBarIconSetting:(NSButton*)sender {
|
||||||
|
|||||||
@@ -24,5 +24,4 @@
|
|||||||
|
|
||||||
@interface BarMenu : NSObject <NSMenuDelegate>
|
@interface BarMenu : NSObject <NSMenuDelegate>
|
||||||
- (void)updateBarIcon;
|
- (void)updateBarIcon;
|
||||||
- (void)asyncReloadUnreadCountAndUpdateBarIcon;
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -54,13 +54,14 @@
|
|||||||
// Unread counter
|
// Unread counter
|
||||||
self.unreadCountTotal = 0;
|
self.unreadCountTotal = 0;
|
||||||
[self updateBarIcon];
|
[self updateBarIcon];
|
||||||
[self asyncReloadUnreadCountAndUpdateBarIcon];
|
[self asyncReloadUnreadCountAndUpdateBarIcon:nil];
|
||||||
|
|
||||||
// Register for notifications
|
// Register for notifications
|
||||||
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedUpdated:) name:kNotificationFeedUpdated object:nil];
|
[[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(networkChanged:) name:kNotificationNetworkStatusChanged object:nil];
|
||||||
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(unreadCountChanged:) name:kNotificationTotalUnreadCountChanged 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;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,13 +71,21 @@
|
|||||||
|
|
||||||
#pragma mark - Update Menu Bar Icon
|
#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 {
|
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(), ^{
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
self.unreadCountTotal = [StoreCoordinator unreadCountForIndexPathString:nil];
|
self.unreadCountTotal = [StoreCoordinator unreadCountForIndexPathString:nil];
|
||||||
[self updateBarIcon];
|
[self updateBarIcon];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Update menu bar icon and text according to unread count and user preferences.
|
/// Update menu bar icon and text according to unread count and user preferences.
|
||||||
- (void)updateBarIcon {
|
- (void)updateBarIcon {
|
||||||
@@ -117,23 +126,38 @@
|
|||||||
@param notify Notification object contains the unread count difference to the current count. May be negative.
|
@param notify Notification object contains the unread count difference to the current count. May be negative.
|
||||||
*/
|
*/
|
||||||
- (void)unreadCountChanged:(NSNotification*)notify {
|
- (void)unreadCountChanged:(NSNotification*)notify {
|
||||||
self.unreadCountTotal += [[notify object] intValue];
|
self.unreadCountTotal += [[notify object] integerValue];
|
||||||
[self updateBarIcon];
|
[self updateBarIcon];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Callback method fired when feeds have been updated in the background.
|
/// Callback method fired when feeds have been updated in the background.
|
||||||
- (void)feedUpdated:(NSNotification*)notify {
|
- (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) {
|
if (self.barItem.menu.numberOfItems > 0) {
|
||||||
// update items only if menu is already open (e.g., during background update)
|
// update items only if menu is already open (e.g., during background update)
|
||||||
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
||||||
for (NSManagedObjectID *oid in notify.object) {
|
|
||||||
Feed *feed = [moc objectWithID:oid];
|
Feed *feed = [moc objectWithID:oid];
|
||||||
if (!feed) continue;
|
if ([feed isKindOfClass:[Feed class]]) {
|
||||||
NSMenu *menu = [self fixUnreadCountForSubmenus:feed];
|
NSMenu *menu = [self fixUnreadCountForSubmenus:feed];
|
||||||
if (!menu || menu.numberOfItems > 0)
|
if (!flag) [self rebuiltFeedArticle:feed inMenu:menu]; // deepest menu level, feed items
|
||||||
[self rebuiltFeedArticle:feed inMenu:menu]; // deepest menu level, feed items
|
|
||||||
}
|
}
|
||||||
[self.barItem.menu autoEnableMenuHeader:(self.unreadCountTotal > 0)]; // once per multi-feed update
|
|
||||||
[moc reset];
|
[moc reset];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,18 +169,27 @@
|
|||||||
*/
|
*/
|
||||||
- (nullable NSMenu*)fixUnreadCountForSubmenus:(Feed*)feed {
|
- (nullable NSMenu*)fixUnreadCountForSubmenus:(Feed*)feed {
|
||||||
NSMenu *menu = self.barItem.menu;
|
NSMenu *menu = self.barItem.menu;
|
||||||
|
[menu autoEnableMenuHeader:(self.unreadCountTotal > 0)];
|
||||||
for (FeedGroup *parent in [feed.group allParents]) {
|
for (FeedGroup *parent in [feed.group allParents]) {
|
||||||
NSInteger offset = [menu feedDataOffset];
|
NSInteger itemIndex = [menu feedDataOffset] + parent.sortIndex;
|
||||||
NSMenuItem *item = [menu itemAtIndex:offset + parent.sortIndex];
|
NSMenuItem *item = [menu itemAtIndex:itemIndex];
|
||||||
NSInteger unread = [item setTitleAndUnreadCount:parent];
|
NSInteger unread = [item setTitleAndUnreadCount:parent];
|
||||||
menu = item.submenu;
|
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)
|
if (!menu || menu.numberOfItems == 0)
|
||||||
return nil;
|
return nil;
|
||||||
if (unread == 0) // if != 0 then 'setTitleAndUnreadCount' was successful (UserPrefs visible)
|
if (unread == 0) // if != 0 then 'setTitleAndUnreadCount' was successful (UserPrefs visible)
|
||||||
unread = [menu coreDataUnreadCount];
|
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.
|
@param menu Deepest menu level which contains only feed items.
|
||||||
*/
|
*/
|
||||||
- (void)rebuiltFeedArticle:(Feed*)feed inMenu:(NSMenu*)menu {
|
- (void)rebuiltFeedArticle:(Feed*)feed inMenu:(NSMenu*)menu {
|
||||||
|
if (!menu || menu.numberOfItems == 0) // not opened yet
|
||||||
|
return;
|
||||||
if (self.currentOpenMenu != menu) {
|
if (self.currentOpenMenu != menu) {
|
||||||
// if the menu isn't open, re-create it dynamically instead
|
// if the menu isn't open, re-create it dynamically instead
|
||||||
menu.itemArray.firstObject.parentItem.submenu = [menu cleanInstanceCopy];
|
menu.itemArray.firstObject.parentItem.submenu = [menu cleanInstanceCopy];
|
||||||
@@ -360,6 +395,7 @@
|
|||||||
Called when user clicks on 'Update all feeds' in the main menu (only).
|
Called when user clicks on 'Update all feeds' in the main menu (only).
|
||||||
*/
|
*/
|
||||||
- (void)updateAllFeeds:(NSMenuItem*)sender {
|
- (void)updateAllFeeds:(NSMenuItem*)sender {
|
||||||
|
[self asyncReloadUnreadCountAndUpdateBarIcon:nil];
|
||||||
[FeedDownload forceUpdateAllFeeds];
|
[FeedDownload forceUpdateAllFeeds];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,8 +37,9 @@
|
|||||||
+ (NSInteger)numberRootItemsInContext:(NSManagedObjectContext*)moc;
|
+ (NSInteger)numberRootItemsInContext:(NSManagedObjectContext*)moc;
|
||||||
+ (NSArray<FeedGroup*>*)sortedListOfRootObjectsInContext:(NSManagedObjectContext*)moc;
|
+ (NSArray<FeedGroup*>*)sortedListOfRootObjectsInContext:(NSManagedObjectContext*)moc;
|
||||||
// Restore sound state
|
// Restore sound state
|
||||||
+ (void)deleteUnreferencedFeeds;
|
+ (NSUInteger)deleteAllGroups;
|
||||||
+ (void)restoreFeedCountsAndIndexPaths:(NSArray<NSManagedObjectID*>*)list;
|
+ (NSUInteger)deleteUnreferenced;
|
||||||
|
+ (void)restoreFeedCountsAndIndexPaths;
|
||||||
+ (NSArray<Feed*>*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc;
|
+ (NSArray<Feed*>*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc;
|
||||||
+ (NSArray<Feed*>*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc;
|
+ (NSArray<Feed*>*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc;
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -24,8 +24,6 @@
|
|||||||
#import "AppHook.h"
|
#import "AppHook.h"
|
||||||
#import "Feed+Ext.h"
|
#import "Feed+Ext.h"
|
||||||
|
|
||||||
#import <RSXML/RSXML.h>
|
|
||||||
|
|
||||||
@implementation StoreCoordinator
|
@implementation StoreCoordinator
|
||||||
|
|
||||||
#pragma mark - Managing contexts
|
#pragma mark - Managing contexts
|
||||||
@@ -40,7 +38,7 @@
|
|||||||
NSManagedObjectContext *context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType];
|
NSManagedObjectContext *context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType];
|
||||||
[context setParentContext:[self getMainContext]];
|
[context setParentContext:[self getMainContext]];
|
||||||
context.undoManager = nil;
|
context.undoManager = nil;
|
||||||
context.automaticallyMergesChangesFromParent = YES;
|
//context.automaticallyMergesChangesFromParent = YES;
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,46 +176,64 @@
|
|||||||
#pragma mark - Restore Sound State
|
#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 {
|
+ (NSUInteger)batchDelete:(NSEntityDescription*)entity nullAttribute:(NSString*)column inContext:(NSManagedObjectContext*)moc {
|
||||||
NSManagedObjectContext *moc = [self getMainContext];
|
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: entity.name];
|
||||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
|
if (column && column.length > 0) {
|
||||||
fr.predicate = [NSPredicate predicateWithFormat:@"group = NULL"];
|
// 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];
|
NSBatchDeleteRequest *bdr = [[NSBatchDeleteRequest alloc] initWithFetchRequest:fr];
|
||||||
|
bdr.resultType = NSBatchDeleteResultTypeCount;
|
||||||
NSError *err;
|
NSError *err;
|
||||||
[moc executeRequest:bdr error:&err];
|
NSBatchDeleteResult *lol = [moc executeRequest:bdr error:&err];
|
||||||
if (err) NSLog(@"%@", err);
|
if (err) NSLog(@"%@", err);
|
||||||
|
return [lol.result unsignedIntegerValue];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Iterate over all @c Feed and re-calculate @c unreadCount, @c articleCount and @c indexPath.
|
Delete all @c FeedGroup items.
|
||||||
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.
|
|
||||||
*/
|
*/
|
||||||
+ (void)restoreFeedCountsAndIndexPaths:(NSArray<NSManagedObjectID*>*)list {
|
+ (NSUInteger)deleteAllGroups {
|
||||||
NSManagedObjectContext *moc = [self getMainContext];
|
NSManagedObjectContext *moc = [self getMainContext];
|
||||||
if (!list) {
|
NSUInteger deleted = [self batchDelete:FeedGroup.entity nullAttribute:nil inContext:moc];
|
||||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
|
[self saveContext:moc andParent:YES];
|
||||||
[fr setResultType:NSManagedObjectIDResultType];
|
return deleted;
|
||||||
list = [self fetchAllRows:fr inContext:moc];
|
|
||||||
}
|
}
|
||||||
[moc performBlock:^{
|
|
||||||
for (NSManagedObjectID *moi in list) {
|
/**
|
||||||
Feed *f = [moc objectWithID:moi];
|
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.
|
||||||
if ([f isKindOfClass:[Feed class]])
|
*/
|
||||||
[f resetArticleCountAndIndexPathString];
|
+ (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];
|
||||||
}
|
}
|
||||||
[self saveContext:moc andParent:YES];
|
[self saveContext:moc andParent:YES];
|
||||||
}];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @return All @c Feed items where @c articles.count @c == @c 0
|
/// @return All @c Feed items where @c articles.count @c == @c 0
|
||||||
+ (NSArray<Feed*>*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc {
|
+ (NSArray<Feed*>*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc {
|
||||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
|
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
|
||||||
// More accurate but with subquery on FeedArticle: "count(articles) == 0"
|
fr.predicate = [NSPredicate predicateWithFormat:@"articles.@count == 0"];
|
||||||
fr.predicate = [NSPredicate predicateWithFormat:@"articleCount == 0"];
|
|
||||||
return [self fetchAllRows:fr inContext:moc];
|
return [self fetchAllRows:fr inContext:moc];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user