From 080991ebc4c05fba8e1d4c0824587476e309ab69 Mon Sep 17 00:00:00 2001 From: relikd Date: Sun, 28 Oct 2018 15:25:52 +0100 Subject: [PATCH] Auto update feeds. Menu rebuild so far only after close. --- README.md | 19 +- .../DBv1.xcdatamodel/contents | 26 ++- baRSS/FeedDownload.h | 6 +- baRSS/FeedDownload.m | 178 +++++++++++++++++- baRSS/Preferences/FeedConfig+Ext.h | 3 + baRSS/Preferences/FeedConfig+Ext.m | 25 +++ baRSS/Preferences/Feeds Tab/ModalFeedEdit.m | 25 ++- baRSS/Status Bar Menu/BarMenu.m | 32 +++- baRSS/StoreCoordinator.h | 4 +- baRSS/StoreCoordinator.m | 88 +++++++-- 10 files changed, 357 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 74f1f93..88a2d00 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ ToDo - [ ] Tick mark feed items based on prefs - [ ] Open a few links (# editable) - [ ] Performance: Update menu partially - - [ ] Start on login + - [x] Start on login - [x] Make it system default application - [ ] Display license info (e.g., RSXML) - [ ] Short article names @@ -47,14 +47,16 @@ ToDo - [ ] Status menu - [ ] Update menu header after mark (un)read - [ ] Pause updates functionality - - [ ] Update all feeds functionality + - [x] Update all feeds functionality + - [ ] Hold only relevant information in memory - [ ] Edit feed - [ ] Show statistics - [ ] How often gets the feed updated (min, max, avg) - [ ] Automatically choose best interval? - - [ ] Auto fix 301 Redirect or ask user + - [ ] Show time of next update + - [x] Auto fix 301 Redirect or ask user - [ ] Make `feed://` URLs clickable - [ ] Feeds with authentication - [ ] Show proper feed icon @@ -64,15 +66,18 @@ ToDo - [ ] Other - [ ] App Icon - [ ] Translate text to different languages - - [ ] Automatically update feeds with chosen interval - - [ ] Reuse ETag and Modification date - - [ ] Append only new items, keep sorting - - [ ] Delete old ones eventually + - [x] Automatically update feeds with chosen interval + - [x] Reuse ETag and Modification date + - ~~[ ] Append only new items, keep sorting~~ + - [x] Delete old ones eventually + - [x] Pause on internet connection lost + - [ ] Download with ephemeral url session? - [ ] Purge cache - [ ] Manually or automatically - [ ] Add something to restore a broken state - [ ] Code Documentation (mostly methods) - [ ] Add Sandboxing + - [ ] Disable Startup checkbox (or other workaround) - [ ] Additional features diff --git a/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents b/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents index 88ba908..66f0c83 100644 --- a/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents +++ b/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents @@ -1,9 +1,6 @@ - + - - - @@ -11,15 +8,17 @@ + - - + + - + + @@ -33,9 +32,16 @@ + + + + + + - - - + + + + \ No newline at end of file diff --git a/baRSS/FeedDownload.h b/baRSS/FeedDownload.h index 7de3d8e..fd0aa9f 100644 --- a/baRSS/FeedDownload.h +++ b/baRSS/FeedDownload.h @@ -24,5 +24,9 @@ #import @interface FeedDownload : NSObject -+ (void)getFeed:(NSString *)url block:(void(^)(RSParsedFeed *feed, NSError* error, NSHTTPURLResponse* response))block; ++ (void)newFeed:(NSString *)url block:(void(^)(RSParsedFeed *feed, NSError* error, NSHTTPURLResponse* response))block; ++ (void)registerNetworkChangeNotification; ++ (void)unregisterNetworkChangeNotification; ++ (BOOL)isNetworkReachable; ++ (void)scheduleNextUpdate:(BOOL)forceUpdate; @end diff --git a/baRSS/FeedDownload.m b/baRSS/FeedDownload.m index 105f202..62f17bc 100644 --- a/baRSS/FeedDownload.m +++ b/baRSS/FeedDownload.m @@ -21,21 +21,37 @@ // SOFTWARE. #import "FeedDownload.h" +#import "StoreCoordinator.h" +#import + +static SCNetworkReachabilityRef _reachability = NULL; +static BOOL _isReachable = NO; + @implementation FeedDownload -+ (void)getFeed:(NSString *)url block:(void(^)(RSParsedFeed *feed, NSError* error, NSHTTPURLResponse* response))block { ++ (NSMutableURLRequest*)newRequestURL:(NSString*)url { NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]]; req.timeoutInterval = 30; req.cachePolicy = NSURLRequestReloadIgnoringCacheData; // [req setValue:@"Mon, 10 Sep 2018 10:32:19 GMT" forHTTPHeaderField:@"If-Modified-Since"]; // [req setValue:@"wII2pETT9EGmlqyCHBFJpm25/7w" forHTTPHeaderField:@"If-None-Match"]; // ETag - [[[NSURLSession sharedSession] dataTaskWithRequest:req completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) { + return req; +} + ++ (NSURLRequest*)newRequest:(FeedConfig*)config { + NSMutableURLRequest *req = [self newRequestURL:config.url]; + NSString* etag = [config.meta.httpEtag stringByReplacingOccurrencesOfString:@"-gzip" withString:@""]; + if (config.meta.httpModified.length > 0) + [req setValue:config.meta.httpModified forHTTPHeaderField:@"If-Modified-Since"]; + if (etag.length > 0) + [req setValue:etag forHTTPHeaderField:@"If-None-Match"]; // ETag + return req; +} + ++ (void)newFeed:(NSString *)url block:(void(^)(RSParsedFeed *feed, NSError* error, NSHTTPURLResponse* response))block { + [[[NSURLSession sharedSession] dataTaskWithRequest:[self newRequestURL:url] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) { NSHTTPURLResponse* httpResponse = (NSHTTPURLResponse*)response; -// NSString* etag = [httpResponse allHeaderFields][@"Etag"]; -// if (etag.length > 5 && [[etag substringFromIndex:etag.length - 5] isEqualToString:@"-gzip"]) { -// etag = [etag substringToIndex:etag.length - 5]; -// } if (error || [httpResponse statusCode] == 304) { block(nil, error, httpResponse); return; @@ -47,4 +63,154 @@ }] resume]; } + +#pragma mark - Update existing feeds - + + ++ (void)scheduleNextUpdate:(BOOL)forceUpdate { + static NSTimer *_updateTimer; + @synchronized (_updateTimer) { + if (_updateTimer) { + [_updateTimer invalidate]; + _updateTimer = nil; + } + } + if (!_isReachable) return; // cancel timer entirely (will be restarted once connection exists) + NSDate *nextTime = [NSDate dateWithTimeIntervalSinceNow:0.2]; + if (!forceUpdate) { + nextTime = [StoreCoordinator nextScheduledUpdate]; + if (!nextTime || [nextTime timeIntervalSinceNow] < 0) { // mostly, if app was closed for a long time + nextTime = [NSDate dateWithTimeIntervalSinceNow:2]; // TODO: retry in 2 sec? + } + } + NSTimeInterval tolerance = [nextTime timeIntervalSinceNow] * 0.15; + _updateTimer = [NSTimer timerWithTimeInterval:0 target:[self class] selector:@selector(scheduledUpdateTimer:) userInfo:@(forceUpdate) repeats:NO]; + _updateTimer.fireDate = nextTime; + _updateTimer.tolerance = (tolerance < 1 ? 1 : tolerance); // at least 1 sec + [[NSRunLoop mainRunLoop] addTimer:_updateTimer forMode:NSRunLoopCommonModes]; +} + ++ (void)scheduledUpdateTimer:(NSTimer*)timer { + NSLog(@"fired"); + BOOL forceAll = [timer.userInfo boolValue]; + // TODO: check internet connection + // TODO: disable menu item 'update all' during update + NSArray *list = [StoreCoordinator getListOfFeedsThatNeedUpdate:forceAll]; + if (list.count == 0) { + NSLog(@"ERROR: Something went wrong, timer fired too early."); + // thechnically should never happen, anyway we need to reset the timer + [self scheduleNextUpdate:NO]; // NO, since forceAll will get ALL items and shouldn't be 0 + return; // nothing to do here + } + NSUndoManager *um = list.firstObject.managedObjectContext.undoManager; + [um beginUndoGrouping]; + dispatch_group_t group = dispatch_group_create(); + for (FeedConfig *c in list) { + [self downloadFeedForConfig:c group:group]; + } + dispatch_group_notify(group, dispatch_get_main_queue(), ^{ + [um endUndoGrouping]; + [self scheduleNextUpdate:NO]; // after forced update, continue regular cycle + }); +} + ++ (void)downloadFeedForConfig:(FeedConfig*)config group:(dispatch_group_t)group { + if (!_isReachable) return; + dispatch_group_enter(group); + [[[NSURLSession sharedSession] dataTaskWithRequest:[self newRequest:config] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) { + [config.managedObjectContext.undoManager beginUndoGrouping]; + if (error) { + int16_t n = config.errorCount + 1; + config.errorCount = (n < 1 ? 1 : (n > 19 ? 19 : n)); // between: 2 sec and 6 days + NSTimeInterval retryWaitTime = pow(2, config.errorCount); // 2^n seconds + config.scheduled = [NSDate dateWithTimeIntervalSinceNow:retryWaitTime]; + // TODO: remove logging + NSLog(@"Error loading: %@ (%d)", response.URL, config.errorCount); + } else { + config.errorCount = 0; // reset counter + [self downloadSuccessful:data forFeed:config response:(NSHTTPURLResponse*)response]; + } + [config.managedObjectContext.undoManager endUndoGrouping]; + dispatch_group_leave(group); + }] resume]; +} + ++ (void)downloadSuccessful:(NSData*)data forFeed:(FeedConfig*)config response:(NSHTTPURLResponse*)http { + if ([http statusCode] != 304) { + // should be fine to call synchronous since dataTask is already in the background (always? proof?) + RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:config.url]; + RSParsedFeed *parsed = RSParseFeedSync(xml, NULL); + if (parsed) { + // TODO: add support for media player? + // + [StoreCoordinator overwriteConfig:config withFeed:parsed]; + } + } + config.meta.httpModified = [http allHeaderFields][@"Date"]; // @"Expires", @"Last-Modified" + config.meta.httpEtag = [http allHeaderFields][@"Etag"]; + // Don't update redirected url since it happened in the background; User may not recognize url + [config calculateAndSetScheduled]; + [config mergeChangesAndSave]; + [[NSNotificationCenter defaultCenter] postNotificationName:@"baRSS-notification-feed-updated" object:config]; +} + + +#pragma mark - Network Connection - + + ++ (BOOL)isNetworkReachable { return _isReachable; } + ++ (void)registerNetworkChangeNotification { + // https://stackoverflow.com/questions/11240196/notification-when-wifi-connected-os-x + if (_reachability != NULL) return; + _reachability = SCNetworkReachabilityCreateWithName(NULL, "1.1.1.1"); + if (_reachability == NULL) return; + // If reachability information is available now, we don't get a callback later + SCNetworkConnectionFlags flags; + if (SCNetworkReachabilityGetFlags(_reachability, &flags)) + networkReachabilityCallback(_reachability, flags, NULL); + if (!SCNetworkReachabilitySetCallback(_reachability, networkReachabilityCallback, NULL) || + !SCNetworkReachabilityScheduleWithRunLoop(_reachability, [[NSRunLoop currentRunLoop] getCFRunLoop], kCFRunLoopCommonModes)) + { + CFRelease(_reachability); + _reachability = NULL; + } +} + ++ (void)unregisterNetworkChangeNotification { + if (_reachability != NULL) { + SCNetworkReachabilitySetCallback(_reachability, nil, nil); + SCNetworkReachabilitySetDispatchQueue(_reachability, nil); + CFRelease(_reachability); + _reachability = NULL; + } +} + +static void networkReachabilityCallback(SCNetworkReachabilityRef target, SCNetworkConnectionFlags flags, void *object) { + if (_reachability == NULL) + return; + _isReachable = [FeedDownload hasConnectivity:flags]; + [[NSNotificationCenter defaultCenter] postNotificationName:@"baRSS-notification-network-status-change" + object:[NSNumber numberWithBool:_isReachable]]; + if (_isReachable) { + NSLog(@"reachable"); + } else { + NSLog(@"not reachable"); + } + // schedule regardless of state (if not reachable timer will be canceled) + [FeedDownload scheduleNextUpdate:NO]; +} + ++ (BOOL)hasConnectivity:(SCNetworkReachabilityFlags)flags { + if ((flags & kSCNetworkReachabilityFlagsReachable) == 0) + return NO; + if ((flags & kSCNetworkReachabilityFlagsConnectionRequired) == 0) + return YES; + if ((flags & kSCNetworkReachabilityFlagsInterventionRequired) == 0 && + ((flags & kSCNetworkReachabilityFlagsConnectionOnDemand) != 0 || + (flags & kSCNetworkReachabilityFlagsConnectionOnTraffic) != 0)) + return YES; // no-intervention AND ( on-demand OR on-traffic ) + return NO; +} + @end diff --git a/baRSS/Preferences/FeedConfig+Ext.h b/baRSS/Preferences/FeedConfig+Ext.h index cc69de5..a9301c4 100644 --- a/baRSS/Preferences/FeedConfig+Ext.h +++ b/baRSS/Preferences/FeedConfig+Ext.h @@ -42,8 +42,11 @@ typedef BOOL (^FeedConfigRecursiveItemsBlock) (FeedConfig *parent, FeedItem *ite @property (getter=typ, setter=setTyp:) FeedConfigType typ; @property (readonly) NSArray *sortedChildren; +@property (readonly) NSIndexPath *indexPath; - (BOOL)descendantFeedItems:(FeedConfigRecursiveItemsBlock)block; +- (void)calculateAndSetScheduled; +- (void)mergeChangesAndSave; - (NSString*)readableRefreshString; - (NSString*)readableDescription; @end diff --git a/baRSS/Preferences/FeedConfig+Ext.m b/baRSS/Preferences/FeedConfig+Ext.m index 4cdaca6..c3cc52a 100644 --- a/baRSS/Preferences/FeedConfig+Ext.m +++ b/baRSS/Preferences/FeedConfig+Ext.m @@ -40,6 +40,12 @@ return [self.children sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]]]; } +- (NSIndexPath *)indexPath { + if (self.parent == nil) + return [NSIndexPath indexPathWithIndex:(NSUInteger)self.sortIndex]; + return [self.parent.indexPath indexPathByAddingIndex:(NSUInteger)self.sortIndex]; +} + /** Iterate over all descendant @c FeedItems in sub groups @@ -61,6 +67,25 @@ return YES; } +/// @return Time interval respecting the selected unit. E.g., returns @c 180 for @c '3m' +- (NSTimeInterval)timeInterval { + static const int unit[] = {1, 60, 3600, 86400, 604800}; // smhdw + return self.refreshNum * unit[self.refreshUnit % 5]; +} + +/// Calculate date from @c refreshNum and @c refreshUnit and set as next scheduled feed update. +- (void)calculateAndSetScheduled { + self.scheduled = [[NSDate date] dateByAddingTimeInterval:[self timeInterval]]; +} + +/// Update item with @c mergeChanges:YES and save the context +- (void)mergeChangesAndSave { + [self.managedObjectContext performBlockAndWait:^{ + [self.managedObjectContext refreshObject:self mergeChanges:YES]; + [self.managedObjectContext save:nil]; + }]; +} + /// @return Formatted string for update interval ( e.g., @c 30m or @c 12h ) - (NSString*)readableRefreshString { return [NSString stringWithFormat:@"%d%c", self.refreshNum, [@"smhdw" characterAtIndex:self.refreshUnit % 5]]; diff --git a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m index e960d6d..bba594d 100644 --- a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m +++ b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m @@ -35,6 +35,8 @@ @property (weak) IBOutlet NSPopover *warningPopover; @property (copy) NSString *previousURL; +@property (copy) NSString *httpDate; +@property (copy) NSString *httpEtag; @property (strong) NSError *feedError; @property (strong) RSParsedFeed *feedResult; @@ -107,15 +109,19 @@ item.refreshUnit = (int16_t)self.refreshUnit.indexOfSelectedItem; if (self.shouldDeletePrevArticles) { + [StoreCoordinator overwriteConfig:item withFeed:self.feedResult]; [item.managedObjectContext performBlockAndWait:^{ - if (item.feed) - [item.managedObjectContext deleteObject:(NSManagedObject*)item.feed]; - if (self.feedResult) - item.feed = [StoreCoordinator createFeedFrom:self.feedResult inContext:item.managedObjectContext]; + // TODO: move to separate function and add icon download + if (!item.meta) { + item.meta = [[FeedMeta alloc] initWithEntity:FeedMeta.entity insertIntoManagedObjectContext:item.managedObjectContext]; + } + item.meta.httpEtag = self.httpEtag; + item.meta.httpModified = self.httpDate; }]; } if ([item.managedObjectContext hasChanges]) { self.objectIsModified = YES; + [item calculateAndSetScheduled]; [item.managedObjectContext performBlockAndWait:^{ [item.managedObjectContext refreshObject:item mergeChanges:YES]; }]; @@ -146,11 +152,16 @@ self.feedError = nil; [self.spinnerURL startAnimation:nil]; [self.spinnerName startAnimation:nil]; - [FeedDownload getFeed:self.previousURL block:^(RSParsedFeed *result, NSError *error, NSHTTPURLResponse* response) { + [FeedDownload newFeed:self.previousURL block:^(RSParsedFeed *result, NSError *error, NSHTTPURLResponse* response) { self.feedResult = result; -// [httpResponse allHeaderFields][@"Date"]; // @"Expires", @"Last-Modified" -// [httpResponse allHeaderFields][@"Etag"]; + self.httpDate = [response allHeaderFields][@"Date"]; // @"Expires", @"Last-Modified" + self.httpEtag = [response allHeaderFields][@"Etag"]; dispatch_async(dispatch_get_main_queue(), ^{ + if (response && ![response.URL.absoluteString isEqualToString:self.url.stringValue]) { + // URL was redirected, so replace original text field value with new one + self.url.stringValue = response.URL.absoluteString; + self.previousURL = self.url.stringValue; + } // TODO: play error sound? self.feedError = error; // warning indicator .hidden is bound to feedError self.objectNeedsSaving = YES; // stays YES if this block runs after updateRepresentedObject: diff --git a/baRSS/Status Bar Menu/BarMenu.m b/baRSS/Status Bar Menu/BarMenu.m index df848f1..f7f2189 100644 --- a/baRSS/Status Bar Menu/BarMenu.m +++ b/baRSS/Status Bar Menu/BarMenu.m @@ -22,6 +22,7 @@ #import "BarMenu.h" #import "StoreCoordinator.h" +#import "FeedDownload.h" #import "DrawImage.h" #import "Preferences.h" #import "NSMenuItem+Info.h" @@ -44,12 +45,34 @@ self.barItem = [NSStatusBar.systemStatusBar statusItemWithLength:NSVariableStatusItemLength]; self.barItem.highlightMode = YES; [self rebuildMenu]; -// [self donothing]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(networkChange:) name:@"baRSS-notification-network-status-change" object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedUpdated:) name:@"baRSS-notification-feed-updated" object:nil]; + [FeedDownload registerNetworkChangeNotification]; + [FeedDownload performSelectorInBackground:@selector(scheduleNextUpdate:) withObject:[NSNumber numberWithBool:NO]]; return self; } +- (void)dealloc { + [FeedDownload unregisterNetworkChangeNotification]; + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +- (void)networkChange:(NSNotification*)notify { + BOOL available = [[notify object] boolValue]; + [self.barItem.menu itemWithTag:TagUpdateFeed].enabled = available; + [self updateBarIcon]; + // TODO: Disable 'update all' menu item? +} + +- (void)feedUpdated:(NSNotification*)notify { + FeedConfig *config = notify.object; + NSLog(@"%@", config.indexPath); + [self rebuildMenu]; +} + - (void)rebuildMenu { self.barItem.menu = [self generateMainMenu]; + [self updateBarIcon]; } - (void)donothing { @@ -79,13 +102,14 @@ */ - (void)updateBarIcon { // TODO: Option: icon choice + // TODO: Show paused icon if no internet connection dispatch_async(dispatch_get_main_queue(), ^{ if (self.unreadCountTotal > 0 && [UserPrefs defaultYES:@"globalUnreadCount"]) { self.barItem.title = [NSString stringWithFormat:@"%d", self.unreadCountTotal]; } else { self.barItem.title = @""; } - + // BOOL hasNet = [FeedDownload isNetworkReachable]; if (self.unreadCountTotal > 0 && [UserPrefs defaultYES:@"tintMenuBarIcon"]) { self.barItem.image = [RSSIcon templateIcon:16 tint:[NSColor rssOrange]]; } else { @@ -122,7 +146,6 @@ } } [self updateMenuHeaderEnabled:menu hasUnread:(self.unreadCountTotal > 0)]; - [self updateBarIcon]; [menu addItem:[NSMenuItem separatorItem]]; @@ -285,7 +308,8 @@ } - (void)updateAllFeeds:(NSMenuItem*)sender { - NSLog(@"1update all"); + // TODO: Disable 'update all' menu item during update? + [FeedDownload scheduleNextUpdate:YES]; } /** diff --git a/baRSS/StoreCoordinator.h b/baRSS/StoreCoordinator.h index 351e74d..77632fb 100644 --- a/baRSS/StoreCoordinator.h +++ b/baRSS/StoreCoordinator.h @@ -30,6 +30,8 @@ + (void)saveContext:(NSManagedObjectContext*)context; + (void)deleteUnreferencedFeeds; + (NSArray*)sortedFeedConfigItems; ++ (NSArray*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll; ++ (NSDate*)nextScheduledUpdate; + (id)objectWithID:(NSManagedObjectID*)objID; -+ (Feed*)createFeedFrom:(RSParsedFeed*)obj inContext:(NSManagedObjectContext*)context; ++ (void)overwriteConfig:(FeedConfig*)config withFeed:(RSParsedFeed*)obj; @end diff --git a/baRSS/StoreCoordinator.m b/baRSS/StoreCoordinator.m index f46bf33..364075b 100644 --- a/baRSS/StoreCoordinator.m +++ b/baRSS/StoreCoordinator.m @@ -63,30 +63,92 @@ return result; } ++ (NSArray*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll { + NSManagedObjectContext *moc = [self getContext]; + NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedConfig.entity.name]; + if (!forceAll) { + fr.predicate = [NSPredicate predicateWithFormat:@"type = %d AND scheduled <= %@", FEED, [NSDate date]]; + } else { + fr.predicate = [NSPredicate predicateWithFormat:@"type = %d", FEED]; + } + NSError *err; + NSArray *result = [moc executeFetchRequest:fr error:&err]; + if (err) NSLog(@"%@", err); + return result; +} + ++ (NSDate*)nextScheduledUpdate { + NSExpression *exp = [NSExpression expressionForFunction:@"min:" arguments:@[[NSExpression expressionForKeyPath:@"scheduled"]]]; + NSExpressionDescription *expDesc = [[NSExpressionDescription alloc] init]; + [expDesc setName:@"earliestDate"]; + [expDesc setExpression:exp]; + [expDesc setExpressionResultType:NSDateAttributeType]; + + NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedConfig.entity.name]; + fr.predicate = [NSPredicate predicateWithFormat:@"type = %d", FEED]; + [fr setResultType:NSDictionaryResultType]; + [fr setPropertiesToFetch:@[expDesc]]; + + NSError *err; + NSArray *fetchResults = [[self getContext] executeFetchRequest:fr error:&err]; + if (err) NSLog(@"%@", err); + return [fetchResults firstObject][@"earliestDate"]; // can be nil +} + + (id)objectWithID:(NSManagedObjectID*)objID { return [[self getContext] objectWithID:objID]; } -+ (Feed*)createFeedFrom:(RSParsedFeed*)obj inContext:(NSManagedObjectContext*)context { + ++ (void)overwriteConfig:(FeedConfig*)config withFeed:(RSParsedFeed*)obj { + NSArray *readURLs = [self alreadyReadURLsInFeed:config.feed]; + [config.managedObjectContext performBlockAndWait:^{ + if (config.feed) + [config.managedObjectContext deleteObject:(NSManagedObject*)config.feed]; + if (obj) { + config.feed = [StoreCoordinator createFeedFrom:obj inContext:config.managedObjectContext alreadyRead:readURLs]; + } + }]; +} + +#pragma mark - Helper methods - + ++ (FeedItem*)createFeedItemFrom:(RSParsedArticle*)entry inContext:(NSManagedObjectContext*)context { + FeedItem *b = [[FeedItem alloc] initWithEntity:FeedItem.entity insertIntoManagedObjectContext:context]; + b.guid = entry.guid; + b.title = entry.title; + b.abstract = entry.abstract; + b.body = entry.body; + b.author = entry.author; + b.link = entry.link; + b.published = entry.datePublished; + return b; +} + ++ (Feed*)createFeedFrom:(RSParsedFeed*)obj inContext:(NSManagedObjectContext*)context alreadyRead:(NSArray*)urls { Feed *a = [[Feed alloc] initWithEntity:Feed.entity insertIntoManagedObjectContext:context]; a.title = obj.title; a.subtitle = obj.subtitle; a.link = obj.link; - for (RSParsedArticle *entry in obj.articles) { - FeedItem *b = [[FeedItem alloc] initWithEntity:FeedItem.entity insertIntoManagedObjectContext:context]; - b.guid = entry.guid; - b.title = entry.title; - b.abstract = entry.abstract; - b.body = entry.body; - b.author = entry.author; - b.link = entry.link; - b.published = entry.datePublished; - // TODO: remove NSLog() - if (!entry.datePublished) - NSLog(@"No date for feed '%@'", obj.urlString); + for (RSParsedArticle *article in obj.articles) { + FeedItem *b = [self createFeedItemFrom:article inContext:context]; + if ([urls containsObject:b.link]) { + b.unread = NO; + } [a addItemsObject:b]; } return a; } ++ (NSArray*)alreadyReadURLsInFeed:(Feed*)local { + if (!local || !local.items) return nil; + NSMutableArray *mArr = [NSMutableArray arrayWithCapacity:local.items.count]; + for (FeedItem *f in local.items) { + if (!f.unread) { + [mArr addObject:f.link]; + } + } + return mArr; +} + @end