From 59d0ec7cca326871d75d7b3af4bca7d506fa3bbd Mon Sep 17 00:00:00 2001 From: relikd Date: Tue, 11 Dec 2018 14:57:40 +0100 Subject: [PATCH] Make 'feed://' URLs clickable. Append feeds automatically to root. --- Cartfile.resolved | 2 +- README.md | 4 +- baRSS/AppHook.m | 15 +++-- baRSS/Categories/Feed+Ext.m | 72 ++++++++++++++++----- baRSS/Categories/FeedGroup+Ext.h | 8 +-- baRSS/Categories/FeedMeta+Ext.h | 8 ++- baRSS/Categories/FeedMeta+Ext.m | 2 +- baRSS/FeedDownload.h | 3 +- baRSS/FeedDownload.m | 103 ++++++++++++++++++++++--------- baRSS/Status Bar Menu/BarMenu.m | 2 - 10 files changed, 159 insertions(+), 60 deletions(-) diff --git a/Cartfile.resolved b/Cartfile.resolved index 76f5e07..78e96cb 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1 +1 @@ -github "relikd/RSXML" "6bf8f713596c1d3e253780cf7f6bd62843dc12a7" +github "relikd/RSXML" "f012a6fa3cb8882a17762d92f3c41e49abfd3985" diff --git a/README.md b/README.md index 876be96..2c46bb5 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ ToDo - [ ] Automatically choose best interval? - [ ] Show time of next update - [x] Auto fix 301 Redirect or ask user - - [ ] Make `feed://` URLs clickable + - [x] Make `feed://` URLs clickable - [ ] Feeds with authentication - [ ] Show proper feed icon - [ ] Download and store icon file @@ -86,7 +86,7 @@ ToDo - [ ] Notification Center - [ ] Sleep timer. (e.g., disable updates during working hours) - [ ] Pure image feed? (show images directly in menu) - - ~~[ ] Infinite storage. (load more button)~~ + - [ ] ~~Infinite storage. (load more button)~~ - [ ] Automatically open feed items? - [ ] Per feed launch application (e.g., for podcasts) - [ ] Per group setting to exclude unread count from menu bar diff --git a/baRSS/AppHook.m b/baRSS/AppHook.m index a07cdc7..b310aed 100644 --- a/baRSS/AppHook.m +++ b/baRSS/AppHook.m @@ -22,6 +22,7 @@ #import "AppHook.h" #import "BarMenu.h" +#import "FeedDownload.h" @implementation AppHook @@ -40,17 +41,23 @@ - (void)applicationDidFinishLaunching:(NSNotification *)aNotification { printf("up and running\n"); -// https://feeds.feedburner.com/simpledesktops +// feed://https://feeds.feedburner.com/simpledesktops + [FeedDownload registerNetworkChangeNotification]; // will call update scheduler } - (void)applicationWillTerminate:(NSNotification *)aNotification { - + [FeedDownload unregisterNetworkChangeNotification]; } - (void)handleGetURLEvent:(NSAppleEventDescriptor *)event withReplyEvent:(NSAppleEventDescriptor *)replyEvent { NSString *url = [[event paramDescriptorForKeyword:keyDirectObject] stringValue]; - // TODO: Open feed edit sheet in preferences - NSLog(@"%@", url); + if ([url hasPrefix:@"feed:"]) { + // TODO: handle other app schemes like configuration export / import + url = [url substringFromIndex:5]; + if ([url hasPrefix:@"//"]) + url = [url substringFromIndex:2]; + [FeedDownload autoDownloadAndParseURL:url]; + } } diff --git a/baRSS/Categories/Feed+Ext.m b/baRSS/Categories/Feed+Ext.m index 9751094..fc60b98 100644 --- a/baRSS/Categories/Feed+Ext.m +++ b/baRSS/Categories/Feed+Ext.m @@ -44,8 +44,10 @@ self.indexPath = pthStr; } + #pragma mark - Update Feed Items - + /** Replace feed title, subtitle and link (if changed). Also adds new articles and removes old ones. */ @@ -55,10 +57,15 @@ if (![self.link isEqualToString:obj.link]) self.link = obj.link; int32_t unreadBefore = self.unreadCount; + // Add and remove articles NSMutableSet *urls = [[self.articles valueForKeyPath:@"link"] mutableCopy]; [self addMissingArticles:obj updateLinks:urls]; // will remove links in 'urls' that should be kept if (urls.count > 0) [self deleteArticlesWithLink:urls]; // remove old, outdated articles + // Get new total article count and post unread-count-change notification + int32_t totalCount = (int32_t)self.articles.count; + if (self.articleCount != totalCount) + self.articleCount = totalCount; if (flag) { NSNumber *cDiff = [NSNumber numberWithInteger:self.unreadCount - unreadBefore]; [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged object:cDiff]; @@ -66,35 +73,56 @@ } /** - Append new articles and increment their sortIndex. Update article counter and unread counter on the way. - + Append new articles and increment their sortIndex. Update unread counter on the way. + + @note + New articles should be in ascending order without any gaps in between. + If new article is disjunct from the article before, assume a deleted article re-appeared and mark it as read. + @param urls Input will be used to identify new articles. Output will contain URLs that aren't present in the feed anymore. - @return @c YES if new items were added, @c NO otherwise. */ -- (BOOL)addMissingArticles:(RSParsedFeed*)obj updateLinks:(NSMutableSet*)urls { - int latestID = [[self.articles valueForKeyPath:@"@max.sortIndex"] intValue]; - __block int newOnes = 0; - [obj.articles enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(RSParsedArticle * _Nonnull article, BOOL * _Nonnull stop) { +- (void)addMissingArticles:(RSParsedFeed*)obj updateLinks:(NSMutableSet*)urls { + int32_t newOnes = 0; + int32_t currentIndex = [[self.articles valueForKeyPath:@"@min.sortIndex"] intValue]; + FeedArticle *lastInserted = nil; + BOOL hasGapBetweenNewArticles = NO; + + for (RSParsedArticle *article in [obj.articles reverseObjectEnumerator]) { // reverse enumeration ensures correct article order if ([urls containsObject:article.link]) { [urls removeObject:article.link]; + FeedArticle *storedArticle = [self findArticleWithLink:article.link]; // TODO: use two synced arrays? + if (storedArticle && storedArticle.sortIndex != currentIndex) { + storedArticle.sortIndex = currentIndex; + } + hasGapBetweenNewArticles = YES; } else { newOnes += 1; - [self insertArticle:article atIndex:latestID + newOnes]; + if (hasGapBetweenNewArticles && lastInserted) { // gap with at least one article inbetween + lastInserted.unread = NO; + NSLog(@"Ghost item: %@", lastInserted.title); + newOnes -= 1; + } + hasGapBetweenNewArticles = NO; + lastInserted = [self insertArticle:article atIndex:currentIndex]; } - }]; - if (newOnes == 0) return NO; - self.articleCount += newOnes; - self.unreadCount += newOnes; // new articles are by definition unread - return YES; + currentIndex += 1; + } + if (hasGapBetweenNewArticles && lastInserted) { + lastInserted.unread = NO; + NSLog(@"Ghost item: %@", lastInserted.title); + newOnes -= 1; + } + if (newOnes > 0) + self.unreadCount += newOnes; // new articles are by definition unread } /** Create article based on input and insert into core data storage. */ -- (void)insertArticle:(RSParsedArticle*)entry atIndex:(int)idx { +- (FeedArticle*)insertArticle:(RSParsedArticle*)entry atIndex:(int32_t)idx { FeedArticle *fa = [[FeedArticle alloc] initWithEntity:FeedArticle.entity insertIntoManagedObjectContext:self.managedObjectContext]; - fa.sortIndex = (int32_t)idx; + fa.sortIndex = idx; fa.unread = YES; fa.guid = entry.guid; fa.title = entry.title; @@ -104,6 +132,7 @@ fa.link = entry.link; fa.published = entry.datePublished; [self addArticlesObject:fa]; + return fa; } /** @@ -126,8 +155,10 @@ } } + #pragma mark - Article Properties - + /** @return Articles sorted by attribute @c sortIndex with descending order (newest items first). */ @@ -137,6 +168,17 @@ return [self.articles sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:NO]]]; } +/** + Iterate over all Articles and return the one where @c .link matches. Or @c nil if no matching article found. + */ +- (FeedArticle*)findArticleWithLink:(NSString*)url { + for (FeedArticle *a in self.articles) { + if ([a.link isEqualToString:url]) + return a; + } + return nil; +} + /** For all articles set @c unread @c = @c NO diff --git a/baRSS/Categories/FeedGroup+Ext.h b/baRSS/Categories/FeedGroup+Ext.h index c66d18c..99d9db1 100644 --- a/baRSS/Categories/FeedGroup+Ext.h +++ b/baRSS/Categories/FeedGroup+Ext.h @@ -24,12 +24,10 @@ @interface FeedGroup (Ext) /// Enum type to distinguish different @c FeedGroup types: @c GROUP, @c FEED, @c SEPARATOR -typedef enum int16_t { +typedef NS_ENUM(int16_t, FeedGroupType) { /// Other types: @c GROUP, @c FEED, @c SEPARATOR - GROUP = 0, - FEED = 1, - SEPARATOR = 2 -} FeedGroupType; + GROUP = 0, FEED = 1, SEPARATOR = 2 +}; @property (readonly) FeedGroupType typ; diff --git a/baRSS/Categories/FeedMeta+Ext.h b/baRSS/Categories/FeedMeta+Ext.h index 38abaa5..c60bee5 100644 --- a/baRSS/Categories/FeedMeta+Ext.h +++ b/baRSS/Categories/FeedMeta+Ext.h @@ -23,12 +23,18 @@ #import "FeedMeta+CoreDataClass.h" @interface FeedMeta (Ext) +/// Easy memorable enum type for refresh unit index +typedef NS_ENUM(int16_t, RefreshUnitType) { + /// Other types: @c GROUP, @c FEED, @c SEPARATOR + RefreshUnitSeconds = 0, RefreshUnitMinutes = 1, RefreshUnitHours = 2, RefreshUnitDays = 3, RefreshUnitWeeks = 4 +}; + - (void)setErrorAndPostponeSchedule; - (void)calculateAndSetScheduled; - (void)setEtag:(NSString*)etag modified:(NSString*)modified; - (void)setEtagAndModified:(NSHTTPURLResponse*)http; -- (BOOL)setURL:(NSString*)url refresh:(int32_t)refresh unit:(int16_t)unit; +- (BOOL)setURL:(NSString*)url refresh:(int32_t)refresh unit:(RefreshUnitType)unit; - (NSString*)readableRefreshString; @end diff --git a/baRSS/Categories/FeedMeta+Ext.m b/baRSS/Categories/FeedMeta+Ext.m index bf94eb9..b7ac863 100644 --- a/baRSS/Categories/FeedMeta+Ext.m +++ b/baRSS/Categories/FeedMeta+Ext.m @@ -59,7 +59,7 @@ @return @c YES if refresh interval has changed */ -- (BOOL)setURL:(NSString*)url refresh:(int32_t)refresh unit:(int16_t)unit { +- (BOOL)setURL:(NSString*)url refresh:(int32_t)refresh unit:(RefreshUnitType)unit { BOOL intervalChanged = (self.refreshNum != refresh || self.refreshUnit != unit); if (![self.url isEqualToString:url]) self.url = url; if (self.refreshNum != refresh) self.refreshNum = refresh; diff --git a/baRSS/FeedDownload.h b/baRSS/FeedDownload.h index 0125a90..5aeecf0 100644 --- a/baRSS/FeedDownload.h +++ b/baRSS/FeedDownload.h @@ -28,7 +28,8 @@ + (void)registerNetworkChangeNotification; + (void)unregisterNetworkChangeNotification; // Scheduled feed update -+ (void)newFeed:(NSString *)url block:(void(^)(RSParsedFeed *feed, NSError* error, NSHTTPURLResponse* response))block; ++ (void)newFeed:(NSString *)urlStr block:(void(^)(RSParsedFeed *feed, NSError *error, NSHTTPURLResponse *response))block; ++ (void)autoDownloadAndParseURL:(NSString*)url; + (void)scheduleUpdateForUpcomingFeeds; + (void)forceUpdateAllFeeds; // User interaction diff --git a/baRSS/FeedDownload.m b/baRSS/FeedDownload.m index b8b265f..130f938 100644 --- a/baRSS/FeedDownload.m +++ b/baRSS/FeedDownload.m @@ -25,6 +25,7 @@ #import "StoreCoordinator.h" #import "Feed+Ext.h" #import "FeedMeta+Ext.h" +#import "FeedGroup+Ext.h" #import @@ -151,10 +152,15 @@ static BOOL _nextUpdateIsForced = NO; /// @return New request with no caching policy and timeout interval of 30 seconds. -+ (NSMutableURLRequest*)newRequestURL:(NSString*)url { - NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]]; - req.timeoutInterval = 30; - req.cachePolicy = NSURLRequestReloadIgnoringCacheData; ++ (NSMutableURLRequest*)newRequestURL:(NSString*)urlStr { + NSURL *url = [NSURL URLWithString:urlStr]; + if (!url.scheme) { + url = [NSURL URLWithString:[NSString stringWithFormat:@"http://%@", urlStr]]; // usually will redirect to https if necessary + } + NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:url]; + req.cachePolicy = NSURLRequestReloadIgnoringLocalCacheData; + req.HTTPShouldHandleCookies = NO; +// req.timeoutInterval = 30; // [req setValue:@"Mon, 10 Sep 2018 10:32:19 GMT" forHTTPHeaderField:@"If-Modified-Since"]; // [req setValue:@"wII2pETT9EGmlqyCHBFJpm25/7w" forHTTPHeaderField:@"If-None-Match"]; // ETag return req; @@ -168,25 +174,25 @@ static BOOL _nextUpdateIsForced = NO; [req setValue:meta.modified forHTTPHeaderField:@"If-Modified-Since"]; if (etag.length > 0) [req setValue:etag forHTTPHeaderField:@"If-None-Match"]; // ETag + if (!_nextUpdateIsForced) // any FeedMeta-request that is not forced, is a background update + req.networkServiceType = NSURLNetworkServiceTypeBackground; return req; } /** Perform feed download request from URL alone. Not updating any @c Feed item. */ -+ (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) { ++ (void)newFeed:(NSString *)urlStr block:(void(^)(RSParsedFeed *feed, NSError *error, NSHTTPURLResponse *response))block { + [[[NSURLSession sharedSession] dataTaskWithRequest:[self newRequestURL:urlStr] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) { NSHTTPURLResponse* httpResponse = (NSHTTPURLResponse*)response; if (error || [httpResponse statusCode] == 304) { block(nil, error, httpResponse); return; } - RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:url]; + RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:urlStr]; RSParseFeed(xml, ^(RSParsedFeed * _Nullable parsedFeed, NSError * _Nullable err) { - if (!err && (!parsedFeed || parsedFeed.articles.count == 0)) { // TODO: this should be fixed in RSXMLParser - NSString *errDesc = NSLocalizedString(@"URL does not contain a RSS feed. Can't parse feed items.", nil); - err = [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorUnsupportedURL userInfo:@{NSLocalizedDescriptionKey: errDesc}]; - } + NSAssert(err || parsedFeed, @"Only parse error XOR parsed result can be set. Not both. Neither none."); + // TODO: Need for error?: "URL does not contain a RSS feed. Can't parse feed items." block(parsedFeed, err, httpResponse); }); }] resume]; @@ -203,26 +209,26 @@ static BOOL _nextUpdateIsForced = NO; return; dispatch_group_enter(group); [[[NSURLSession sharedSession] dataTaskWithRequest:[self newRequest:feed.meta] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) { - [feed.managedObjectContext performBlock:^{ - // core data block inside of url session block; otherwise access will EXC_BAD_INSTRUCTION - if (error) { + NSHTTPURLResponse *header = (NSHTTPURLResponse*)response; + RSParsedFeed *parsed = nil; // can stay nil if !error and statusCode = 304 + BOOL hasError = (error != nil); + if (!error && [header statusCode] != 304) { // only parse if modified + RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:header.URL.absoluteString]; + // should be fine to call synchronous since dataTask is already in the background (always? proof?) + parsed = RSParseFeedSync(xml, &error); // reuse error + if (error || !parsed || parsed.articles.count == 0) { + hasError = YES; + } + } + [feed.managedObjectContext performBlock:^{ // otherwise access on feed will EXC_BAD_INSTRUCTION + if (hasError) { [feed.meta setErrorAndPostponeSchedule]; } else { - [feed.meta setEtagAndModified:(NSHTTPURLResponse*)response]; + feed.meta.errorCount = 0; // reset counter + [feed.meta setEtagAndModified:header]; [feed.meta calculateAndSetScheduled]; - - if ([(NSHTTPURLResponse*)response statusCode] != 304) { // only parse if modified - // should be fine to call synchronous since dataTask is already in the background (always? proof?) - RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:feed.meta.url]; - RSParsedFeed *parsed = RSParseFeedSync(xml, NULL); - if (parsed && parsed.articles.count > 0) { - [feed updateWithRSS:parsed postUnreadCountChange:YES]; - feed.meta.errorCount = 0; // reset counter - } else { - [feed.meta setErrorAndPostponeSchedule]; // replaces date of 'calculateAndSetScheduled' - } - } - // TODO: save changes for this feed only? + if (parsed) [feed updateWithRSS:parsed postUnreadCountChange:YES]; + // TODO: save changes for this feed only? / Partial Update //[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:feed.objectID]; } dispatch_group_leave(group); @@ -230,6 +236,47 @@ static BOOL _nextUpdateIsForced = NO; }] resume]; } +/** + Download feed at url and append to persistent store in root folder. + On error present user modal alert. + */ ++ (void)autoDownloadAndParseURL:(NSString*)url { + [FeedDownload newFeed:url block:^(RSParsedFeed *feed, NSError *error, NSHTTPURLResponse *response) { + if (error) { + dispatch_async(dispatch_get_main_queue(), ^{ + [NSApp presentError:error]; + }); + } else { + [FeedDownload autoParseFeedAndAppendToRoot:feed response:response]; + } + }]; +} + +/** + Create new @c FeedGroup, @c Feed, @c FeedMeta and @c FeedArticle instances and save them to the persistent store. + Appends feed to the end of the root folder, so that the user will immediatelly see it. + Update duration is set to the default of 30 minutes. + + @param rss Parsed RSS feed. If @c @c nil no feed object will be added. + @param response May be @c nil but then feed download URL will not be set. + */ ++ (void)autoParseFeedAndAppendToRoot:(nonnull RSParsedFeed*)rss response:(NSHTTPURLResponse*)response { + if (!rss || rss.articles.count == 0) return; + NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; + NSUInteger idx = [StoreCoordinator sortedObjectIDsForParent:nil isFeed:NO inContext:moc].count; + FeedGroup *newFeed = [FeedGroup newGroup:FEED inContext:moc]; + FeedMeta *meta = newFeed.feed.meta; + [meta setURL:response.URL.absoluteString refresh:30 unit:RefreshUnitMinutes]; + [meta calculateAndSetScheduled]; + [newFeed setName:rss.title andRefreshString:[meta readableRefreshString]]; + [meta setEtagAndModified:response]; + [newFeed.feed updateWithRSS:rss postUnreadCountChange:YES]; + newFeed.sortIndex = (int32_t)idx; + [newFeed.feed calculateAndSetIndexPathString]; + [StoreCoordinator saveContext:moc andParent:YES]; + [moc reset]; +} + #pragma mark - Network Connection & Reachability - diff --git a/baRSS/Status Bar Menu/BarMenu.m b/baRSS/Status Bar Menu/BarMenu.m index ee06bdb..d5db652 100644 --- a/baRSS/Status Bar Menu/BarMenu.m +++ b/baRSS/Status Bar Menu/BarMenu.m @@ -60,12 +60,10 @@ [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(networkChanged:) name:kNotificationNetworkStatusChanged object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(unreadCountChanged:) name:kNotificationTotalUnreadCountChanged object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(asyncReloadUnreadCountAndUpdateBarIcon) name:kNotificationTotalUnreadCountReset object:nil]; - [FeedDownload registerNetworkChangeNotification]; // will call update scheduler return self; } - (void)dealloc { - [FeedDownload unregisterNetworkChangeNotification]; [[NSNotificationCenter defaultCenter] removeObserver:self]; }