Make 'feed://' URLs clickable. Append feeds automatically to root.
This commit is contained in:
@@ -1 +1 @@
|
||||
github "relikd/RSXML" "6bf8f713596c1d3e253780cf7f6bd62843dc12a7"
|
||||
github "relikd/RSXML" "f012a6fa3cb8882a17762d92f3c41e49abfd3985"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.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 -
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user