Make 'feed://' URLs clickable. Append feeds automatically to root.

This commit is contained in:
relikd
2018-12-11 14:57:40 +01:00
parent 821e40a68b
commit 59d0ec7cca
10 changed files with 159 additions and 60 deletions

View File

@@ -1 +1 @@
github "relikd/RSXML" "6bf8f713596c1d3e253780cf7f6bd62843dc12a7"
github "relikd/RSXML" "f012a6fa3cb8882a17762d92f3c41e49abfd3985"

View File

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

View File

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

View File

@@ -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<NSString*> *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<NSString*>*)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<NSString*>*)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;
}
}];
if (newOnes == 0) return NO;
self.articleCount += newOnes;
hasGapBetweenNewArticles = NO;
lastInserted = [self insertArticle:article atIndex:currentIndex];
}
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
return YES;
}
/**
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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,6 +25,7 @@
#import "StoreCoordinator.h"
#import "Feed+Ext.h"
#import "FeedMeta+Ext.h"
#import "FeedGroup+Ext.h"
#import <SystemConfiguration/SystemConfiguration.h>
@@ -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 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?
[feed.meta setEtagAndModified:header];
[feed.meta calculateAndSetScheduled];
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 -

View File

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