OPML export / import + bug fixes + Refactoring (RSXML 2.0, StoreCoordinator, Feed type)
This commit is contained in:
@@ -27,11 +27,14 @@
|
||||
@interface Feed (Ext)
|
||||
// Generator methods / Feed update
|
||||
+ (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context;
|
||||
+ (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc;
|
||||
- (void)calculateAndSetIndexPathString;
|
||||
- (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag;
|
||||
// Article properties
|
||||
- (NSArray<FeedArticle*>*)sortedArticles;
|
||||
- (int)markAllItemsRead;
|
||||
- (int)markAllItemsUnread;
|
||||
// Icon
|
||||
- (NSImage*)iconImage16;
|
||||
- (BOOL)setIcon:(NSImage*)img replaceExisting:(BOOL)overwrite;
|
||||
@end
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
#import "FeedGroup+Ext.h"
|
||||
#import "FeedIcon+CoreDataClass.h"
|
||||
#import "FeedArticle+CoreDataClass.h"
|
||||
#import "StoreCoordinator.h"
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import <RSXML/RSXML.h>
|
||||
@@ -40,6 +41,15 @@
|
||||
return feed;
|
||||
}
|
||||
|
||||
/// Instantiates new @c FeedGroup with @c FEED type, set the update interval to @c 30min and @c sortIndex to last root index.
|
||||
+ (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc {
|
||||
NSInteger lastIndex = [StoreCoordinator numberRootItemsInContext:moc];
|
||||
FeedGroup *fg = [FeedGroup newGroup:FEED inContext:moc];
|
||||
[fg setParent:nil andSortIndex:(int32_t)lastIndex];
|
||||
[fg.feed.meta setRefresh:30 unit:RefreshUnitMinutes];
|
||||
return fg.feed;
|
||||
}
|
||||
|
||||
/// Call @c indexPathString on @c .group and update @c .indexPath if current value is different.
|
||||
- (void)calculateAndSetIndexPathString {
|
||||
NSString *pthStr = [self.group indexPathString];
|
||||
@@ -59,12 +69,14 @@
|
||||
if (![self.subtitle isEqualToString:obj.subtitle]) self.subtitle = obj.subtitle;
|
||||
if (![self.link isEqualToString:obj.link]) self.link = obj.link;
|
||||
|
||||
if (self.group.name.length == 0) // in case a blank group was initialized
|
||||
self.group.name = obj.title;
|
||||
|
||||
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
|
||||
[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)
|
||||
@@ -144,18 +156,21 @@
|
||||
- (void)deleteArticlesWithLink:(NSMutableSet<NSString*>*)urls {
|
||||
if (!urls || urls.count == 0)
|
||||
return;
|
||||
self.articleCount -= (int32_t)urls.count;
|
||||
for (FeedArticle *fa in self.articles) {
|
||||
if ([urls containsObject:fa.link]) {
|
||||
[urls removeObject:fa.link];
|
||||
if (fa.unread)
|
||||
self.unreadCount -= 1;
|
||||
// TODO: keep unread articles?
|
||||
[fa.managedObjectContext deleteObject:fa];
|
||||
[self.managedObjectContext deleteObject:fa];
|
||||
if (urls.count == 0)
|
||||
break;
|
||||
}
|
||||
}
|
||||
NSSet<FeedArticle*> *delArticles = [self.managedObjectContext deletedObjects];
|
||||
if (delArticles.count > 0) {
|
||||
[self removeArticles:delArticles];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -217,14 +232,32 @@
|
||||
return newCount - oldCount;
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Icon -
|
||||
|
||||
|
||||
/**
|
||||
@return Return @c 16x16px image. Either from core data storage or generated default RSS icon.
|
||||
*/
|
||||
- (NSImage*)iconImage16 {
|
||||
NSData *imgData = self.icon.icon;
|
||||
if (imgData) {
|
||||
return [[NSImage alloc] initWithData:imgData];
|
||||
} else {
|
||||
if (imgData)
|
||||
{
|
||||
NSImage *img = [[NSImage alloc] initWithData:imgData];
|
||||
[img setSize:NSMakeSize(16, 16)];
|
||||
return img;
|
||||
}
|
||||
else if (self.articleCount == 0)
|
||||
{
|
||||
static NSImage *warningIcon;
|
||||
if (!warningIcon) {
|
||||
warningIcon = [NSImage imageNamed:NSImageNameCaution];
|
||||
[warningIcon setSize:NSMakeSize(16, 16)];
|
||||
}
|
||||
return warningIcon;
|
||||
}
|
||||
else
|
||||
{
|
||||
static NSImage *defaultRSSIcon;
|
||||
if (!defaultRSSIcon)
|
||||
defaultRSSIcon = [RSSIcon iconWithSize:16];
|
||||
@@ -232,4 +265,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Set (or overwrite) favicon icon or delete relationship if icon is @c nil.
|
||||
|
||||
@param overwrite If @c NO write image only if non is set already. Use @c YES if you want to @c nil.
|
||||
*/
|
||||
- (BOOL)setIcon:(NSImage*)img replaceExisting:(BOOL)overwrite {
|
||||
if (overwrite || !self.icon) { // write if forced or image empty
|
||||
if (img && [img isValid]) {
|
||||
if (!self.icon)
|
||||
self.icon = [[FeedIcon alloc] initWithEntity:FeedIcon.entity insertIntoManagedObjectContext:self.managedObjectContext];
|
||||
self.icon.icon = [img TIFFRepresentation];
|
||||
return YES;
|
||||
} else if (self.icon) {
|
||||
[self.managedObjectContext deleteObject:self.icon];
|
||||
self.icon = nil;
|
||||
return YES;
|
||||
}
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -29,13 +29,16 @@ typedef NS_ENUM(int16_t, FeedGroupType) {
|
||||
GROUP = 0, FEED = 1, SEPARATOR = 2
|
||||
};
|
||||
|
||||
@property (readonly) FeedGroupType typ;
|
||||
/// Overwrites @c type attribute with enum. Use one of: @c GROUP, @c FEED, @c SEPARATOR.
|
||||
@property (nonatomic) FeedGroupType type;
|
||||
|
||||
+ (instancetype)newGroup:(FeedGroupType)type inContext:(NSManagedObjectContext*)context;
|
||||
- (void)setName:(NSString*)name andRefreshString:(NSString*)refreshStr;
|
||||
- (void)setParent:(FeedGroup *)parent andSortIndex:(int32_t)sortIndex;
|
||||
- (void)setNameIfChanged:(NSString*)name;
|
||||
- (NSImage*)groupIconImage16;
|
||||
// Handle children and parents
|
||||
- (NSString*)indexPathString;
|
||||
- (NSArray<FeedGroup*>*)sortedChildren;
|
||||
- (NSMutableArray<FeedGroup*>*)allParents;
|
||||
- (BOOL)iterateSorted:(BOOL)ordered overDescendantFeeds:(void(^)(Feed *feed, BOOL* cancel))block;
|
||||
// Printing
|
||||
|
||||
@@ -27,25 +27,27 @@
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
@implementation FeedGroup (Ext)
|
||||
/// Enum tpye getter see @c FeedGroupType
|
||||
- (FeedGroupType)typ { return (FeedGroupType)self.type; }
|
||||
/// Enum type setter see @c FeedGroupType
|
||||
- (void)setTyp:(FeedGroupType)typ { self.type = typ; }
|
||||
|
||||
|
||||
/// Create new instance and set @c Feed and @c FeedMeta if group type is @c FEED
|
||||
+ (instancetype)newGroup:(FeedGroupType)type inContext:(NSManagedObjectContext*)moc {
|
||||
FeedGroup *fg = [[FeedGroup alloc] initWithEntity: FeedGroup.entity insertIntoManagedObjectContext:moc];
|
||||
fg.typ = type;
|
||||
fg.type = type;
|
||||
if (type == FEED)
|
||||
fg.feed = [Feed newFeedAndMetaInContext:moc];
|
||||
return fg;
|
||||
}
|
||||
|
||||
/// Set name and refreshStr attributes. @note Only values that differ will be updated.
|
||||
- (void)setName:(NSString*)name andRefreshString:(NSString*)refreshStr {
|
||||
if (![self.name isEqualToString: name]) self.name = name;
|
||||
if (![self.refreshStr isEqualToString:refreshStr]) self.refreshStr = refreshStr;
|
||||
- (void)setParent:(FeedGroup *)parent andSortIndex:(int32_t)sortIndex {
|
||||
self.parent = parent;
|
||||
self.sortIndex = sortIndex;
|
||||
if (self.type == FEED)
|
||||
[self.feed calculateAndSetIndexPathString];
|
||||
}
|
||||
|
||||
/// Set @c name attribute but only if value differs.
|
||||
- (void)setNameIfChanged:(NSString*)name {
|
||||
if (![self.name isEqualToString: name])
|
||||
self.name = name;
|
||||
}
|
||||
|
||||
/// @return Return static @c 16x16px NSImageNameFolder image.
|
||||
@@ -112,7 +114,7 @@
|
||||
|
||||
/// @return Simplified description of the feed object.
|
||||
- (NSString*)readableDescription {
|
||||
switch (self.typ) {
|
||||
switch (self.type) {
|
||||
case SEPARATOR: return @"-------------";
|
||||
case GROUP: return [NSString stringWithFormat:@"%@", self.name];
|
||||
case FEED:
|
||||
|
||||
@@ -23,18 +23,19 @@
|
||||
#import "FeedMeta+CoreDataClass.h"
|
||||
|
||||
@interface FeedMeta (Ext)
|
||||
/// Easy memorable enum type for refresh unit index
|
||||
/// Easy memorable @c int16_t enum 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)setSucessfulWithResponse:(NSHTTPURLResponse*)response;
|
||||
|
||||
- (void)setUrlIfChanged:(NSString*)url;
|
||||
- (void)setEtag:(NSString*)etag modified:(NSString*)modified;
|
||||
- (void)setEtagAndModified:(NSHTTPURLResponse*)http;
|
||||
- (BOOL)setURL:(NSString*)url refresh:(int32_t)refresh unit:(RefreshUnitType)unit;
|
||||
- (BOOL)setRefresh:(int32_t)refresh unit:(RefreshUnitType)unit;
|
||||
- (BOOL)setRefreshAndUnitFromInterval:(int32_t)interval;
|
||||
|
||||
- (int32_t)refreshInterval;
|
||||
- (NSString*)readableRefreshString;
|
||||
@end
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
// SOFTWARE.
|
||||
|
||||
#import "FeedMeta+Ext.h"
|
||||
#import "Feed+Ext.h"
|
||||
#import "FeedGroup+Ext.h"
|
||||
|
||||
/// smhdw: [1, 60, 3600, 86400, 604800]
|
||||
static const int32_t RefreshUnitValues[] = {1, 60, 3600, 86400, 604800}; // smhdw
|
||||
|
||||
@implementation FeedMeta (Ext)
|
||||
|
||||
@@ -36,41 +41,70 @@
|
||||
NSLog(@"ERROR: Feed download failed: %@ (errorCount: %d)", self.url, n);
|
||||
}
|
||||
|
||||
- (void)setSucessfulWithResponse:(NSHTTPURLResponse*)response {
|
||||
self.errorCount = 0; // reset counter
|
||||
NSDictionary *header = [response allHeaderFields];
|
||||
[self setEtag:header[@"Etag"] modified:header[@"Date"]]; // @"Expires", @"Last-Modified"
|
||||
[self calculateAndSetScheduled];
|
||||
}
|
||||
|
||||
/// Calculate date from @c refreshNum and @c refreshUnit and set as next scheduled feed update.
|
||||
- (void)calculateAndSetScheduled {
|
||||
NSTimeInterval interval = [self timeInterval]; // 0 if refresh = 0 (update deactivated)
|
||||
NSTimeInterval interval = [self refreshInterval]; // 0 if refresh = 0 (update deactivated)
|
||||
self.scheduled = (interval <= 0 ? nil : [[NSDate date] dateByAddingTimeInterval:interval]);
|
||||
}
|
||||
|
||||
/// Set etag and modified attributes. @note Only values that differ will be updated.
|
||||
/// Set @c url attribute but only if value differs.
|
||||
- (void)setUrlIfChanged:(NSString*)url {
|
||||
if (![self.url isEqualToString:url]) self.url = url;
|
||||
}
|
||||
|
||||
/// Set @c etag and @c modified attributes. Only values that differ will be updated.
|
||||
- (void)setEtag:(NSString*)etag modified:(NSString*)modified {
|
||||
if (![self.etag isEqualToString:etag]) self.etag = etag;
|
||||
if (![self.modified isEqualToString:modified]) self.modified = modified;
|
||||
}
|
||||
|
||||
/// Read header field "Etag" and "Date" and set @c .etag and @c .modified.
|
||||
- (void)setEtagAndModified:(NSHTTPURLResponse*)http {
|
||||
NSDictionary *header = [http allHeaderFields];
|
||||
[self setEtag:header[@"Etag"] modified:header[@"Date"]]; // @"Expires", @"Last-Modified"
|
||||
}
|
||||
|
||||
/**
|
||||
Set download url and refresh interval (popup button selection). @note Only values that differ will be updated.
|
||||
Set @c refresh and @c unit from popup button selection. Only values that differ will be updated.
|
||||
Also, calculate and set new @c scheduled date and update FeedGroup @c refreshStr (if changed).
|
||||
|
||||
@return @c YES if refresh interval has changed
|
||||
*/
|
||||
- (BOOL)setURL:(NSString*)url refresh:(int32_t)refresh unit:(RefreshUnitType)unit {
|
||||
- (BOOL)setRefresh:(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;
|
||||
if (self.refreshUnit != unit) self.refreshUnit = unit;
|
||||
if (self.refreshNum != refresh) self.refreshNum = refresh;
|
||||
if (self.refreshUnit != unit) self.refreshUnit = unit;
|
||||
|
||||
if (intervalChanged) {
|
||||
[self calculateAndSetScheduled];
|
||||
NSString *str = [self readableRefreshString];
|
||||
if (![self.feed.group.refreshStr isEqualToString:str])
|
||||
self.feed.group.refreshStr = str;
|
||||
}
|
||||
return intervalChanged;
|
||||
}
|
||||
|
||||
/**
|
||||
Set properties @c refreshNum and @c refreshUnit to highest possible (integer-dividable-)unit.
|
||||
Only values that differ will be updated.
|
||||
Also, calculate and set new @c scheduled date and update FeedGroup @c refreshStr (if changed).
|
||||
|
||||
@return @c YES if refresh interval has changed
|
||||
*/
|
||||
- (BOOL)setRefreshAndUnitFromInterval:(int32_t)interval {
|
||||
for (RefreshUnitType i = 4; i >= 0; i--) { // start with weeks
|
||||
if (interval % RefreshUnitValues[i] == 0) { // find first unit that is dividable
|
||||
return [self setRefresh:abs(interval) / RefreshUnitValues[i] unit:i];
|
||||
}
|
||||
}
|
||||
return NO; // since loop didn't return, no value was changed
|
||||
}
|
||||
|
||||
/// @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];
|
||||
- (int32_t)refreshInterval {
|
||||
return self.refreshNum * RefreshUnitValues[self.refreshUnit % 5];
|
||||
}
|
||||
|
||||
/// @return Formatted string for update interval ( e.g., @c 30m or @c 12h )
|
||||
|
||||
@@ -31,7 +31,6 @@ static NSString *kNotificationFeedUpdated = @"baRSS-notification-feed-updated";
|
||||
static NSString *kNotificationNetworkStatusChanged = @"baRSS-notification-network-status-changed";
|
||||
static NSString *kNotificationTotalUnreadCountChanged = @"baRSS-notification-total-unread-count-changed";
|
||||
static NSString *kNotificationTotalUnreadCountReset = @"baRSS-notification-total-unread-count-reset";
|
||||
static NSString *kNotificationFaviconDownloadFinished = @"baRSS-notification-favicon-download-finished";
|
||||
|
||||
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));}
|
||||
|
||||
@@ -35,9 +35,22 @@
|
||||
// Downloading
|
||||
+ (void)newFeed:(NSString *)urlStr block:(void(^)(RSParsedFeed *feed, NSError *error, NSHTTPURLResponse *response))block;
|
||||
+ (void)autoDownloadAndParseURL:(NSString*)urlStr;
|
||||
+ (void)backgroundDownloadFavicon:(NSString*)urlStr forFeed:(Feed*)feed;
|
||||
+ (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 ;
|
||||
// User interaction
|
||||
+ (BOOL)allowNetworkConnection;
|
||||
+ (BOOL)isPaused;
|
||||
+ (void)setPaused:(BOOL)flag;
|
||||
@end
|
||||
|
||||
|
||||
/*
|
||||
Developer Tip, error logs see:
|
||||
|
||||
Task <..> HTTP load failed (error code: -1003 [12:8])
|
||||
Task <..> finished with error - code: -1003
|
||||
==> NSURLErrorCannotFindHost in #import <Foundation/NSURLError.h>
|
||||
|
||||
TIC TCP Conn Failed [21:0x1d417fb00]: 1:65 Err(65)
|
||||
==> EHOSTUNREACH in #import <sys/errno.h>
|
||||
*/
|
||||
|
||||
@@ -123,32 +123,40 @@ static BOOL _nextUpdateIsForced = NO;
|
||||
return;
|
||||
NSLog(@"fired");
|
||||
|
||||
__block NSManagedObjectContext *childContext = [StoreCoordinator createChildContext];
|
||||
NSArray<Feed*> *list = [StoreCoordinator getListOfFeedsThatNeedUpdate:_nextUpdateIsForced inContext:childContext];
|
||||
BOOL updateAll = _nextUpdateIsForced;
|
||||
_nextUpdateIsForced = NO;
|
||||
if (list.count == 0) {
|
||||
NSLog(@"ERROR: Something went wrong, timer fired too early.");
|
||||
[childContext reset];
|
||||
childContext = nil;
|
||||
// thechnically should never happen, anyway we need to reset the timer
|
||||
[self resumeUpdates];
|
||||
return; // nothing to do here
|
||||
|
||||
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
||||
NSArray<Feed*> *list = [StoreCoordinator getListOfFeedsThatNeedUpdate:updateAll inContext:moc];
|
||||
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 postChanges:successful andSaveContext:moc];
|
||||
[moc reset];
|
||||
[self resumeUpdates]; // always reset the timer
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
Perform save on context and all parents. Then post @c FeedUpdated notification.
|
||||
Use return value to download additional data.
|
||||
|
||||
@return @c YES if @c (list.count @c > @c 0).
|
||||
Return @c NO if context wasn't saved, and no notification was sent.
|
||||
*/
|
||||
+ (BOOL)postChanges:(NSArray<Feed*>*)changedFeeds andSaveContext:(NSManagedObjectContext*)moc {
|
||||
if (changedFeeds && changedFeeds.count > 0) {
|
||||
[StoreCoordinator saveContext:moc andParent:YES];
|
||||
NSArray<NSManagedObjectID*> *list = [changedFeeds valueForKeyPath:@"objectID"];
|
||||
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:list];
|
||||
return YES;
|
||||
}
|
||||
dispatch_group_t group = dispatch_group_create();
|
||||
for (Feed *feed in list) {
|
||||
[self downloadFeed:feed group:group];
|
||||
}
|
||||
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
|
||||
[StoreCoordinator saveContext:childContext andParent:YES];
|
||||
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:[list valueForKeyPath:@"objectID"]];
|
||||
[childContext reset];
|
||||
childContext = nil;
|
||||
[self resumeUpdates];
|
||||
});
|
||||
return NO;
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Download RSS Feed -
|
||||
#pragma mark - Request Generator -
|
||||
|
||||
|
||||
/// @return Base URL part. E.g., https://stackoverflow.com/a/15897956/10616114 ==> https://stackoverflow.com/
|
||||
+ (NSURL*)hostURL:(NSString*)urlStr {
|
||||
@@ -189,138 +197,209 @@ static BOOL _nextUpdateIsForced = NO;
|
||||
}
|
||||
|
||||
/**
|
||||
Perform feed download request from URL alone. Not updating any @c Feed item.
|
||||
Start download session of RSS or Atom feed, parse feed and return result on the main thread.
|
||||
|
||||
@param block Called when parsing finished or an @c NSURL error occured.
|
||||
If content did not change (status code 304) both, error and result will be @c nil.
|
||||
Will be called on main thread.
|
||||
*/
|
||||
+ (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) {
|
||||
+ (void)parseFeedRequest:(NSURLRequest*)request block:(nonnull void(^)(RSParsedFeed *rss, NSError *error, NSHTTPURLResponse *response))block {
|
||||
[[[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
|
||||
NSHTTPURLResponse* httpResponse = (NSHTTPURLResponse*)response;
|
||||
if (error || [httpResponse statusCode] == 304) {
|
||||
block(nil, error, httpResponse);
|
||||
return;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
block(nil, error, httpResponse); // error = nil if status == 304
|
||||
});
|
||||
} else {
|
||||
RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:httpResponse.URL.absoluteString];
|
||||
RSFeedParser *parser = [RSFeedParser parserWithXMLData:xml];
|
||||
[parser parseAsync:^(RSParsedFeed * _Nullable parsedFeed, NSError * _Nullable err) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
block(parsedFeed, err, httpResponse);
|
||||
});
|
||||
}];
|
||||
}
|
||||
RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:urlStr];
|
||||
RSParseFeed(xml, ^(RSParsedFeed * _Nullable parsedFeed, NSError * _Nullable err) {
|
||||
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];
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Download RSS Feed -
|
||||
|
||||
|
||||
/**
|
||||
Perform feed download request from URL alone. Not updating any @c Feed item.
|
||||
*/
|
||||
+ (void)newFeed:(NSString *)urlStr block:(void(^)(RSParsedFeed *parsed, NSError *error, NSHTTPURLResponse *response))block {
|
||||
[self parseFeedRequest:[self newRequestURL:urlStr] block:block];
|
||||
}
|
||||
|
||||
/**
|
||||
Start download request with existing @c Feed object. Reuses etag and modified headers.
|
||||
|
||||
@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 successful Empty, mutable list that will be returned in @c batchUpdateFeeds:finally:showErrorAlert: finally block
|
||||
@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 {
|
||||
if (![self allowNetworkConnection])
|
||||
+ (void)downloadFeed:(Feed*)feed group:(dispatch_group_t)group
|
||||
errorAlert:(BOOL)alert
|
||||
successful:(nonnull NSMutableArray<Feed*>*)successful
|
||||
failed:(nonnull NSMutableArray<Feed*>*)failed
|
||||
{
|
||||
if (![self allowNetworkConnection]) {
|
||||
[failed addObject:feed];
|
||||
return;
|
||||
}
|
||||
dispatch_group_enter(group);
|
||||
[[[NSURLSession sharedSession] dataTaskWithRequest:[self newRequest:feed.meta] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable 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;
|
||||
}
|
||||
[self parseFeedRequest:[self newRequest:feed.meta] block:^(RSParsedFeed *rss, NSError *error, NSHTTPURLResponse *response) {
|
||||
if (error) {
|
||||
if (alert) [NSApp presentError:error];
|
||||
[feed.meta setErrorAndPostponeSchedule];
|
||||
[failed addObject:feed];
|
||||
} else {
|
||||
[feed.meta setSucessfulWithResponse:response];
|
||||
if (rss) [feed updateWithRSS:rss postUnreadCountChange:YES];
|
||||
// TODO: save changes for this feed only? / Partial Update
|
||||
[successful addObject:feed]; // will be added even if statusCode == 304 (rss == nil)
|
||||
}
|
||||
[feed.managedObjectContext performBlock:^{ // otherwise access on feed will EXC_BAD_INSTRUCTION
|
||||
if (hasError) {
|
||||
[feed.meta setErrorAndPostponeSchedule];
|
||||
} else {
|
||||
feed.meta.errorCount = 0; // reset counter
|
||||
[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);
|
||||
}];
|
||||
}] resume];
|
||||
dispatch_group_leave(group);
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
Download feed at url and append to persistent store in root folder.
|
||||
On error present user modal alert.
|
||||
|
||||
Creates new @c FeedGroup, @c Feed, @c FeedMeta and @c FeedArticle instances and saves them to the persistent store.
|
||||
Update duration is set to the default of 30 minutes.
|
||||
*/
|
||||
+ (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];
|
||||
});
|
||||
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
||||
Feed *f = [Feed appendToRootWithDefaultIntervalInContext:moc];
|
||||
f.meta.url = url;
|
||||
[self batchDownloadRSSAndFavicons:@[f] showErrorAlert:YES rssFinished:^(NSArray<Feed *> *successful, BOOL *cancelFavicons) {
|
||||
*cancelFavicons = ![self postChanges:successful andSaveContext:moc];
|
||||
} finally:^(BOOL successful) {
|
||||
if (successful) {
|
||||
[StoreCoordinator saveContext:moc andParent:YES];
|
||||
} else {
|
||||
[FeedDownload autoParseFeedAndAppendToRoot:feed response:response];
|
||||
[moc rollback];
|
||||
}
|
||||
[moc reset];
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
Perform a download /update request for the feed data and download missing favicons.
|
||||
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 flag If @c YES display Error Popup to user.
|
||||
@param blockXml Called after XML is downloaded and parsed.
|
||||
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
|
||||
showErrorAlert:(BOOL)flag
|
||||
rssFinished:(void(^)(NSArray<Feed*> *successful, BOOL * cancelFavicons))blockXml
|
||||
finally:(void(^)(BOOL successful))blockFavicon
|
||||
{
|
||||
[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);
|
||||
}];
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
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.
|
||||
Create download list of feed URLs and download them all at once. Finally, notify when all finished.
|
||||
|
||||
@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 block Called after all downloads finished @b OR if list is empty (in that case both parameters are @c nil ).
|
||||
*/
|
||||
+ (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];
|
||||
NSString *faviconURL = newFeed.feed.link;
|
||||
if (faviconURL.length == 0)
|
||||
faviconURL = meta.url;
|
||||
[FeedDownload backgroundDownloadFavicon:faviconURL forFeed:newFeed.feed];
|
||||
[moc reset];
|
||||
}
|
||||
|
||||
/**
|
||||
Try to download @c favicon.ico and save downscaled image to persistent store.
|
||||
*/
|
||||
+ (void)backgroundDownloadFavicon:(NSString*)urlStr forFeed:(Feed*)feed {
|
||||
NSManagedObjectID *oid = feed.objectID;
|
||||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
||||
NSImage *img = [self downloadFavicon:urlStr];
|
||||
if (img) {
|
||||
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
||||
[moc performBlock:^{
|
||||
Feed *f = [moc objectWithID:oid];
|
||||
if (!f.icon)
|
||||
f.icon = [[FeedIcon alloc] initWithEntity:FeedIcon.entity insertIntoManagedObjectContext:moc];
|
||||
f.icon.icon = [img TIFFRepresentation];
|
||||
[StoreCoordinator saveContext:moc andParent:YES];
|
||||
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFaviconDownloadFinished object:f.objectID];
|
||||
[moc reset];
|
||||
}];
|
||||
}
|
||||
+ (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);
|
||||
});
|
||||
}
|
||||
|
||||
/// Download favicon located at http://.../ @c favicon.ico and rescale image to @c 16x16.
|
||||
+ (NSImage*)downloadFavicon:(NSString*)urlStr {
|
||||
NSURL *favURL = [[self hostURL:urlStr] URLByAppendingPathComponent:@"favicon.ico"];
|
||||
NSImage *img = [[NSImage alloc] initWithContentsOfURL:favURL];
|
||||
if (!img) return nil;
|
||||
return [NSImage imageWithSize:NSMakeSize(16, 16) flipped:NO drawingHandler:^BOOL(NSRect dstRect) {
|
||||
[img drawInRect:dstRect];
|
||||
return YES;
|
||||
}];
|
||||
|
||||
#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.
|
||||
*/
|
||||
+ (void)batchDownloadFavicons:(NSArray<Feed*> *)list replaceExisting:(BOOL)flag finally:(os_block_t)block {
|
||||
dispatch_group_t group = dispatch_group_create();
|
||||
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);
|
||||
[self downloadFavicon:faviconURL finished:^(NSImage *img) {
|
||||
Feed *feed = [moc objectWithID:oid]; // should also work if context was reset
|
||||
[feed setIcon:img replaceExisting:flag];
|
||||
dispatch_group_leave(group);
|
||||
}];
|
||||
}
|
||||
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
|
||||
if (block) block();
|
||||
});
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
||||
NSURL *favURL = [[self hostURL:urlStr] URLByAppendingPathComponent:@"favicon.ico"];
|
||||
NSImage *img = [[NSImage alloc] initWithContentsOfURL:favURL];
|
||||
if (!img || ![img isValid])
|
||||
img = nil;
|
||||
// if (img.size.width > 16 || img.size.height > 16) {
|
||||
// NSImage *smallImage = [NSImage imageWithSize:NSMakeSize(16, 16) flipped:NO drawingHandler:^BOOL(NSRect dstRect) {
|
||||
// [img drawInRect:dstRect];
|
||||
// return YES;
|
||||
// }];
|
||||
// if (img.TIFFRepresentation.length > smallImage.TIFFRepresentation.length)
|
||||
// img = smallImage;
|
||||
// }
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
block(img);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
@property (copy) NSString *previousURL; // check if changed and avoid multiple download
|
||||
@property (copy) NSString *httpDate;
|
||||
@property (copy) NSString *httpEtag;
|
||||
@property (strong) NSImage *favicon;
|
||||
@property (strong) NSError *feedError; // download error or xml parser error
|
||||
@property (strong) RSParsedFeed *feedResult; // parsed result
|
||||
@property (assign) BOOL didDownloadFeed; // check if feed articles need update
|
||||
@@ -85,6 +86,8 @@
|
||||
[super viewDidLoad];
|
||||
self.previousURL = @"";
|
||||
self.refreshNum.intValue = 30;
|
||||
self.warningIndicator.image = nil;
|
||||
[self.warningIndicator.cell setHighlightsBy:NSNoCellMask];
|
||||
[self populateTextFields:self.feedGroup];
|
||||
}
|
||||
|
||||
@@ -101,6 +104,7 @@
|
||||
if (unit < 0 || unit > self.refreshUnit.numberOfItems - 1)
|
||||
unit = self.refreshUnit.numberOfItems - 1;
|
||||
[self.refreshUnit selectItemAtIndex:unit];
|
||||
self.warningIndicator.image = [fg.feed iconImage16];
|
||||
}
|
||||
|
||||
#pragma mark - Edit Feed Data
|
||||
@@ -111,31 +115,27 @@
|
||||
*/
|
||||
- (void)applyChangesToCoreDataObject {
|
||||
Feed *feed = self.feedGroup.feed;
|
||||
[self.feedGroup setNameIfChanged:self.name.stringValue];
|
||||
FeedMeta *meta = feed.meta;
|
||||
BOOL intervalChanged = [meta setURL:self.previousURL refresh:self.refreshNum.intValue unit:(int16_t)self.refreshUnit.indexOfSelectedItem];
|
||||
if (intervalChanged)
|
||||
[meta calculateAndSetScheduled]; // updateTimer will be scheduled once preferences is closed
|
||||
[self.feedGroup setName:self.name.stringValue andRefreshString:[meta readableRefreshString]];
|
||||
[meta setUrlIfChanged:self.previousURL];
|
||||
[meta setRefresh:self.refreshNum.intValue unit:(int16_t)self.refreshUnit.indexOfSelectedItem]; // updateTimer will be scheduled once preferences is closed
|
||||
if (self.didDownloadFeed) {
|
||||
[meta setEtag:self.httpEtag modified:self.httpDate];
|
||||
[feed updateWithRSS:self.feedResult postUnreadCountChange:YES];
|
||||
}
|
||||
if (!feed.icon) {
|
||||
NSString *faviconURL = feed.link;
|
||||
if (faviconURL.length == 0)
|
||||
faviconURL = meta.url;
|
||||
[FeedDownload backgroundDownloadFavicon:faviconURL forFeed:feed];
|
||||
[feed setIcon:self.favicon replaceExisting:YES];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Prepare UI (nullify @c result, @c error and start @c ProgressIndicator) and perform HTTP request.
|
||||
Articles will be parsed and stored in class variables.
|
||||
This should avoid unnecessary core data operations if user decides to cancel the edit.
|
||||
The save operation will only be executed if user clicks on the 'OK' button.
|
||||
Prepare UI (nullify @c result, @c error and start @c ProgressIndicator).
|
||||
Also disable 'Done' button during download and re-enable after all downloads are finished.
|
||||
*/
|
||||
- (void)downloadRSS {
|
||||
[self.modalSheet setDoneEnabled:NO];
|
||||
- (void)preDownload {
|
||||
[self.modalSheet setDoneEnabled:NO]; // prevent user from closing the dialog during download
|
||||
[self.spinnerURL startAnimation:nil];
|
||||
[self.spinnerName startAnimation:nil];
|
||||
self.warningIndicator.image = nil;
|
||||
self.didDownloadFeed = NO;
|
||||
// Assuming the user has not changed title since the last fetch.
|
||||
// Reset to "" because after download it will be pre-filled with new feed title
|
||||
if ([self.name.stringValue isEqualToString:self.feedResult.title]) {
|
||||
@@ -145,62 +145,91 @@
|
||||
self.feedError = nil;
|
||||
self.httpEtag = nil;
|
||||
self.httpDate = nil;
|
||||
self.didDownloadFeed = NO;
|
||||
[self.spinnerURL startAnimation:nil];
|
||||
[self.spinnerName startAnimation:nil];
|
||||
|
||||
self.favicon = nil;
|
||||
}
|
||||
|
||||
/**
|
||||
All properties will be parsed and stored in class variables.
|
||||
This should avoid unnecessary core data operations if user decides to cancel the edit.
|
||||
The save operation will only be executed if user clicks on the 'OK' button.
|
||||
*/
|
||||
- (void)downloadRSS {
|
||||
if (self.modalSheet.didCloseAndCancel)
|
||||
return;
|
||||
[self preDownload];
|
||||
[FeedDownload newFeed:self.previousURL block:^(RSParsedFeed *result, NSError *error, NSHTTPURLResponse* response) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (self.modalSheet.closeInitiated)
|
||||
return;
|
||||
self.didDownloadFeed = YES;
|
||||
self.feedResult = result;
|
||||
self.feedError = error; // MAIN THREAD!: warning indicator .hidden is bound to feedError
|
||||
self.httpEtag = [response allHeaderFields][@"Etag"];
|
||||
self.httpDate = [response allHeaderFields][@"Date"]; // @"Expires", @"Last-Modified"
|
||||
[self updateTextFieldURL:response.URL.absoluteString andTitle:result.title];
|
||||
// TODO: play error sound?
|
||||
[self.spinnerURL stopAnimation:nil];
|
||||
[self.spinnerName stopAnimation:nil];
|
||||
[self.modalSheet setDoneEnabled:YES];
|
||||
});
|
||||
if (self.modalSheet.didCloseAndCancel)
|
||||
return;
|
||||
self.didDownloadFeed = YES;
|
||||
self.feedResult = result;
|
||||
self.feedError = error;
|
||||
self.httpEtag = [response allHeaderFields][@"Etag"];
|
||||
self.httpDate = [response allHeaderFields][@"Date"]; // @"Expires", @"Last-Modified"
|
||||
[self postDownload:response.URL.absoluteString];
|
||||
}];
|
||||
}
|
||||
|
||||
/// Set UI TextField values to downloaded values. Title will be updated if TextField is empty. URL on redirect.
|
||||
- (void)updateTextFieldURL:(NSString*)responseURL andTitle:(NSString*)feedTitle {
|
||||
// If URL was redirected (e.g., https redirect), replace original text field value with new one
|
||||
/**
|
||||
Update UI TextFields with downloaded values.
|
||||
Title will be updated if TextField is empty. URL on redirect.
|
||||
Finally begin favicon download and return control to user (enable 'Done' button).
|
||||
*/
|
||||
- (void)postDownload:(NSString*)responseURL {
|
||||
if (self.modalSheet.didCloseAndCancel)
|
||||
return;
|
||||
// 1. Stop spinner animation for name field. (keep spinner for URL running until favicon downloaded)
|
||||
// TODO: play error sound?
|
||||
[self.spinnerName stopAnimation:nil];
|
||||
// 2. If URL was redirected, replace original text field value with new one. (e.g., https redirect)
|
||||
if (responseURL.length > 0 && ![responseURL isEqualToString:self.previousURL]) {
|
||||
self.previousURL = responseURL;
|
||||
self.url.stringValue = responseURL;
|
||||
}
|
||||
// Copy feed title to text field. (only if user hasn't set anything else yet)
|
||||
if ([self.name.stringValue isEqualToString:@""] && feedTitle.length > 0) {
|
||||
self.name.stringValue = feedTitle; // no damage to replace an empty string
|
||||
// 3. Copy parsed feed title to text field. (only if user hasn't set anything else yet)
|
||||
NSString *parsedTitle = self.feedResult.title;
|
||||
if (parsedTitle.length > 0 && [self.name.stringValue isEqualToString:@""]) {
|
||||
self.name.stringValue = parsedTitle; // no damage to replace an empty string
|
||||
}
|
||||
// 4. Continue with favicon download (or finish with error)
|
||||
if (self.feedError) {
|
||||
[self finishDownloadWithFavicon:[NSImage imageNamed:NSImageNameCaution]];
|
||||
} else {
|
||||
NSString *faviconURL = self.feedResult.link; // TODO: add support for custom URLs ?
|
||||
if (faviconURL.length == 0)
|
||||
faviconURL = responseURL;
|
||||
[FeedDownload downloadFavicon:faviconURL finished:^(NSImage * _Nullable img) {
|
||||
if (self.modalSheet.didCloseAndCancel)
|
||||
return;
|
||||
self.favicon = img;
|
||||
[self finishDownloadWithFavicon:img];
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
The last step of the download process.
|
||||
Stop spinning animation set favivon image preview (right of url bar) and re-enable 'Done' button.
|
||||
*/
|
||||
- (void)finishDownloadWithFavicon:(NSImage*)img {
|
||||
if (self.modalSheet.didCloseAndCancel)
|
||||
return;
|
||||
[self.warningIndicator.cell setHighlightsBy: (self.feedError ? NSContentsCellMask : NSNoCellMask)];
|
||||
self.warningIndicator.image = img;
|
||||
[self.spinnerURL stopAnimation:nil];
|
||||
[self.modalSheet setDoneEnabled:YES];
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - NSTextField Delegate
|
||||
|
||||
/// Helper method to check whether url was modified since last download.
|
||||
- (BOOL)urlHasChanged {
|
||||
return ![self.previousURL isEqualToString:self.url.stringValue];
|
||||
}
|
||||
|
||||
/// Hide warning button if an error was present but the user changed the url since.
|
||||
- (void)controlTextDidChange:(NSNotification *)obj {
|
||||
if (obj.object == self.url) {
|
||||
self.warningIndicator.hidden = (!self.feedError || [self urlHasChanged]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whenever the user finished entering the url (return key or focus change) perform a download request.
|
||||
- (void)controlTextDidEndEditing:(NSNotification *)obj {
|
||||
if (obj.object == self.url && [self urlHasChanged]) {
|
||||
if (self.modalSheet.closeInitiated)
|
||||
return;
|
||||
self.previousURL = self.url.stringValue;
|
||||
[self downloadRSS];
|
||||
if (obj.object == self.url) {
|
||||
if (![self.previousURL isEqualToString:self.url.stringValue]) {
|
||||
self.previousURL = self.url.stringValue;
|
||||
[self downloadRSS];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,9 +273,7 @@
|
||||
}
|
||||
/// Edit of group finished. Save changes to core data object and perform save operation on delegate.
|
||||
- (void)applyChangesToCoreDataObject {
|
||||
NSString *name = ((NSTextField*)self.view).stringValue;
|
||||
if (![self.feedGroup.name isEqualToString:name])
|
||||
self.feedGroup.name = name;
|
||||
[self.feedGroup setNameIfChanged:((NSTextField*)self.view).stringValue];
|
||||
}
|
||||
@end
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="14113" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="14313.18" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<dependencies>
|
||||
<deployment identifier="macosx"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="14113"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="14313.18"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
@@ -35,11 +35,11 @@
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
<textField verticalHuggingPriority="750" fixedFrame="YES" textCompletion="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Asm-D9-ZfT">
|
||||
<rect key="frame" x="107" y="58" width="193" height="21"/>
|
||||
<rect key="frame" x="107" y="58" width="191" height="21"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
|
||||
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" state="on" borderStyle="bezel" placeholderString="https://example.org/feed.rss" drawsBackground="YES" usesSingleLineMode="YES" id="0Sk-H2-VAC">
|
||||
<font key="font" metaFont="system"/>
|
||||
<color key="textColor" name="textColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
<connections>
|
||||
@@ -56,11 +56,11 @@
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
<textField verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="ab8-rr-HbK">
|
||||
<rect key="frame" x="107" y="29" width="193" height="21"/>
|
||||
<rect key="frame" x="107" y="29" width="191" height="21"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
|
||||
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" state="on" borderStyle="bezel" placeholderString="Example Title" drawsBackground="YES" usesSingleLineMode="YES" id="1ku-vp-T5y">
|
||||
<font key="font" metaFont="system"/>
|
||||
<color key="textColor" name="textColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
@@ -79,7 +79,7 @@
|
||||
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" state="on" borderStyle="bezel" placeholderString="30" drawsBackground="YES" usesSingleLineMode="YES" id="DqU-fT-cIf">
|
||||
<customFormatter key="formatter" id="Lbd-r9-4bc" customClass="StrictUIntFormatter"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
<color key="textColor" name="textColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
@@ -118,7 +118,7 @@
|
||||
<rect key="frame" x="304" y="31" width="16" height="16"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMinY="YES"/>
|
||||
</progressIndicator>
|
||||
<button hidden="YES" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="LWE-Y8-ebl">
|
||||
<button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="LWE-Y8-ebl">
|
||||
<rect key="frame" x="302" y="60" width="18" height="18"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMinY="YES"/>
|
||||
<buttonCell key="cell" type="roundRect" bezelStyle="roundedRect" image="NSCaution" imagePosition="only" alignment="center" refusesFirstResponder="YES" state="on" imageScaling="proportionallyDown" inset="2" id="FAw-6c-Vij">
|
||||
@@ -129,11 +129,6 @@
|
||||
</buttonCell>
|
||||
<connections>
|
||||
<action selector="didClickWarningButton:" target="-2" id="wNa-Cc-jZb"/>
|
||||
<binding destination="-2" name="hidden" keyPath="self.feedError" id="o3F-lJ-LPU">
|
||||
<dictionary key="options">
|
||||
<string key="NSValueTransformerName">NSIsNil</string>
|
||||
</dictionary>
|
||||
</binding>
|
||||
</connections>
|
||||
</button>
|
||||
</subviews>
|
||||
|
||||
31
baRSS/Preferences/Feeds Tab/OpmlExport.h
Normal file
31
baRSS/Preferences/Feeds Tab/OpmlExport.h
Normal file
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// The MIT License (MIT)
|
||||
// Copyright (c) 2018 Oleg Geier
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
// this software and associated documentation files (the "Software"), to deal in
|
||||
// the Software without restriction, including without limitation the rights to
|
||||
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
// of the Software, and to permit persons to whom the Software is furnished to do
|
||||
// so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
@class Feed;
|
||||
|
||||
@interface OpmlExport : NSObject
|
||||
+ (void)showImportDialog:(NSWindow*)window withTreeController:(NSTreeController*)tree;
|
||||
+ (void)showExportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc;
|
||||
@end
|
||||
327
baRSS/Preferences/Feeds Tab/OpmlExport.m
Normal file
327
baRSS/Preferences/Feeds Tab/OpmlExport.m
Normal file
@@ -0,0 +1,327 @@
|
||||
//
|
||||
// The MIT License (MIT)
|
||||
// Copyright (c) 2018 Oleg Geier
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
// this software and associated documentation files (the "Software"), to deal in
|
||||
// the Software without restriction, including without limitation the rights to
|
||||
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
// of the Software, and to permit persons to whom the Software is furnished to do
|
||||
// so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
#import "OpmlExport.h"
|
||||
#import "FeedMeta+Ext.h"
|
||||
#import "FeedGroup+Ext.h"
|
||||
#import "StoreCoordinator.h"
|
||||
#import "FeedDownload.h"
|
||||
#import "Constants.h"
|
||||
|
||||
@implementation OpmlExport
|
||||
|
||||
#pragma mark - Open & Save Panel
|
||||
|
||||
/// Display Open File Panel to select @c .opml file.
|
||||
+ (void)showImportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc success:(nullable void(^)(NSArray<Feed*> *added))block {
|
||||
NSOpenPanel *op = [NSOpenPanel openPanel];
|
||||
op.allowedFileTypes = @[@"opml"];
|
||||
[op beginSheetModalForWindow:window completionHandler:^(NSModalResponse result) {
|
||||
if (result == NSModalResponseOK) {
|
||||
[self importFeedData:op.URL inContext:moc success:block];
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
/// Display Save File Panel to select export destination. All feeds from core data will be exported.
|
||||
+ (void)showExportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc {
|
||||
NSSavePanel *sp = [NSSavePanel savePanel];
|
||||
sp.nameFieldStringValue = [NSString stringWithFormat:@"baRSS feeds %@", [self currentDayAsString]];
|
||||
sp.allowedFileTypes = @[@"opml"];
|
||||
sp.allowsOtherFileTypes = YES;
|
||||
NSView *radioView = [self radioGroupCreate:@[NSLocalizedString(@"Hierarchical", nil),
|
||||
NSLocalizedString(@"Flattened", nil)]];
|
||||
sp.accessoryView = [self viewByPrependingLabel:NSLocalizedString(@"Export format:", nil) toView:radioView];
|
||||
|
||||
[sp beginSheetModalForWindow:window completionHandler:^(NSModalResponse result) {
|
||||
if (result == NSModalResponseOK) {
|
||||
BOOL flattened = ([self radioGroupSelection:radioView] == 1);
|
||||
NSString *exportString = [self exportFeedsHierarchical:!flattened inContext:moc];
|
||||
NSError *error;
|
||||
[exportString writeToURL:sp.URL atomically:YES encoding:NSUTF8StringEncoding error:&error];
|
||||
if (error) {
|
||||
[NSApp presentError:error];
|
||||
}
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
/// 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.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
|
||||
|
||||
|
||||
/**
|
||||
Ask user for permission to import new items (prior import). User can choose to append or replace existing items.
|
||||
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
|
||||
@return @c NO if user clicks 'Cancel' button. @c YES otherwise.
|
||||
*/
|
||||
+ (BOOL)askToAppendOrOverwriteAlert:(RSOPMLItem*)document inContext:(NSManagedObjectContext*)moc {
|
||||
NSUInteger count = [self recursiveNumberOfFeeds:document];
|
||||
NSAlert *alert = [[NSAlert alloc] init];
|
||||
alert.messageText = [NSString stringWithFormat:NSLocalizedString(@"Import of %lu feed items", nil), count];
|
||||
alert.informativeText = NSLocalizedString(@"Do you want to append or replace existing items?", nil);
|
||||
[alert addButtonWithTitle:NSLocalizedString(@"Import", nil)];
|
||||
[alert addButtonWithTitle:NSLocalizedString(@"Cancel", nil)];
|
||||
alert.accessoryView = [self radioGroupCreate:@[NSLocalizedString(@"Append", nil),
|
||||
NSLocalizedString(@"Overwrite", nil)]];
|
||||
NSModalResponse code = [alert runModal];
|
||||
if (code == NSAlertSecondButtonReturn) { // cancel button
|
||||
return NO;
|
||||
}
|
||||
if ([self radioGroupSelection:alert.accessoryView] == 1) { // overwrite selected
|
||||
for (FeedGroup *g in [StoreCoordinator sortedListOfRootObjectsInContext:moc]) {
|
||||
[moc deleteObject:g];
|
||||
}
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
/**
|
||||
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 {
|
||||
NSData *data = [NSData dataWithContentsOfURL:fileURL];
|
||||
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 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];
|
||||
|
||||
for (RSOPMLItem *item in doc.children) {
|
||||
[self importFeed:item parent:nil index:idx inContext:moc appendToList:list];
|
||||
idx += 1;
|
||||
}
|
||||
if (block) block(list);
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
Import single item and recursively repeat import for each child.
|
||||
|
||||
@param item The item to be imported.
|
||||
@param parent The already processed parent item.
|
||||
@param idx @c sortIndex within the @c parent item.
|
||||
@param moc Managed object context.
|
||||
@param list Mutable list where newly inserted @c Feed items will be added.
|
||||
*/
|
||||
+ (void)importFeed:(RSOPMLItem*)item parent:(FeedGroup*)parent index:(int32_t)idx inContext:(NSManagedObjectContext*)moc appendToList:(NSMutableArray<Feed*> *)list {
|
||||
FeedGroupType type = GROUP;
|
||||
if ([item attributeForKey:OPMLXMLURLKey]) {
|
||||
type = FEED;
|
||||
} else if ([item attributeForKey:@"separator"]) { // baRSS specific
|
||||
type = SEPARATOR;
|
||||
}
|
||||
|
||||
FeedGroup *newFeed = [FeedGroup newGroup:type inContext:moc];
|
||||
[newFeed setParent:parent andSortIndex:idx];
|
||||
newFeed.name = (type == SEPARATOR ? @"---" : item.displayName);
|
||||
|
||||
switch (type) {
|
||||
case GROUP:
|
||||
for (NSUInteger i = 0; i < item.children.count; i++) {
|
||||
[self importFeed:item.children[i] parent:newFeed index:(int32_t)i inContext:moc appendToList:list];
|
||||
}
|
||||
break;
|
||||
|
||||
case FEED:
|
||||
@autoreleasepool {
|
||||
FeedMeta *meta = newFeed.feed.meta;
|
||||
meta.url = [item attributeForKey:OPMLXMLURLKey];
|
||||
id refresh = [item attributeForKey:@"refreshInterval"]; // baRSS specific
|
||||
if (refresh) {
|
||||
[meta setRefreshAndUnitFromInterval:(int32_t)[refresh integerValue]];
|
||||
} else {
|
||||
[meta setRefresh:30 unit:RefreshUnitMinutes];
|
||||
}
|
||||
}
|
||||
[list addObject:newFeed.feed];
|
||||
break;
|
||||
|
||||
case SEPARATOR:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Export
|
||||
|
||||
|
||||
/**
|
||||
Initiate export of current core data state. Write opml header and all root items.
|
||||
|
||||
@param flag If @c YES keep parent-child structure intact. If @c NO ignore all parents and add @c Feed items only.
|
||||
@param moc Managed object context.
|
||||
@return Save this string to file.
|
||||
*/
|
||||
+ (NSString*)exportFeedsHierarchical:(BOOL)flag inContext:(NSManagedObjectContext*)moc {
|
||||
NSDictionary *info = @{@"dateCreated" : [NSDate date], @"ownerName" : @"baRSS", OPMLTitleKey : @"baRSS feeds"};
|
||||
RSOPMLItem *doc = [RSOPMLItem itemWithAttributes:info];
|
||||
@autoreleasepool {
|
||||
NSArray<FeedGroup*> *arr = [StoreCoordinator sortedListOfRootObjectsInContext:moc];
|
||||
for (FeedGroup *item in arr) {
|
||||
[self addChild:item toParent:doc hierarchical:flag];
|
||||
}
|
||||
}
|
||||
return [doc exportOPMLAsString];
|
||||
}
|
||||
|
||||
/**
|
||||
Build up @c RSOPMLItem structure recursively. Essentially, re-create same structure as in core data storage.
|
||||
|
||||
@param flag If @c NO don't add groups to export file but continue evaluation of child items.
|
||||
*/
|
||||
+ (void)addChild:(FeedGroup*)item toParent:(RSOPMLItem*)parent hierarchical:(BOOL)flag {
|
||||
RSOPMLItem *child = [RSOPMLItem new];
|
||||
[child setAttribute:item.name forKey:OPMLTitleKey];
|
||||
if (flag || item.type == SEPARATOR || item.feed) {
|
||||
[parent addChild:child]; // dont add item if item is group and hierarchical == NO
|
||||
}
|
||||
|
||||
if (item.type == SEPARATOR) {
|
||||
[child setAttribute:@"true" forKey:@"separator"]; // baRSS specific
|
||||
} else if (item.feed) {
|
||||
[child setAttribute:@"rss" forKey:OPMLTypeKey];
|
||||
[child setAttribute:item.feed.link forKey:OPMLHMTLURLKey];
|
||||
[child setAttribute:item.feed.meta.url forKey:OPMLXMLURLKey];
|
||||
NSNumber *refreshNum = [NSNumber numberWithInteger:[item.feed.meta refreshInterval]];
|
||||
[child setAttribute:refreshNum forKey:@"refreshInterval"]; // baRSS specific
|
||||
} else {
|
||||
for (FeedGroup *subItem in [item sortedChildren]) {
|
||||
[self addChild:subItem toParent:(flag ? child : parent) hierarchical:flag];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Helper
|
||||
|
||||
|
||||
/// @return Date formatted as @c yyyy-MM-dd
|
||||
+ (NSString*)currentDayAsString {
|
||||
NSDateComponents *now = [[NSCalendar currentCalendar] components:NSCalendarUnitDay | NSCalendarUnitMonth | NSCalendarUnitYear fromDate:[NSDate date]];
|
||||
return [NSString stringWithFormat:@"%04ld-%02ld-%02ld", now.year, now.month, now.day];
|
||||
}
|
||||
|
||||
/// Count items where @c xmlURL key is set.
|
||||
+ (NSUInteger)recursiveNumberOfFeeds:(RSOPMLItem*)document {
|
||||
if ([document attributeForKey:OPMLXMLURLKey]) {
|
||||
return 1;
|
||||
} else {
|
||||
NSUInteger sum = 0;
|
||||
for (RSOPMLItem *child in document.children) {
|
||||
sum += [self recursiveNumberOfFeeds:child];
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
}
|
||||
|
||||
/// Solely used to group radio buttons
|
||||
+ (void)donothing {}
|
||||
|
||||
/// Create a new view with as many @c NSRadioButton items as there are strings. Buttons @c tag is equal to the array index.
|
||||
+ (NSView*)radioGroupCreate:(NSArray<NSString*>*)titles {
|
||||
if (titles.count == 0)
|
||||
return nil;
|
||||
|
||||
NSRect viewRect = NSMakeRect(0, 0, 0, 8);
|
||||
NSInteger idx = (NSInteger)titles.count;
|
||||
NSView *v = [[NSView alloc] init];
|
||||
for (NSString *title in titles.reverseObjectEnumerator) {
|
||||
idx -= 1;
|
||||
NSButton *btn = [NSButton radioButtonWithTitle:title target:self action:@selector(donothing)];
|
||||
btn.tag = idx;
|
||||
btn.frame = NSOffsetRect(btn.frame, 0, viewRect.size.height);
|
||||
viewRect.size.height += btn.frame.size.height + 2; // 2px padding
|
||||
if (viewRect.size.width < btn.frame.size.width)
|
||||
viewRect.size.width = btn.frame.size.width;
|
||||
[v addSubview:btn];
|
||||
if (idx == 0)
|
||||
btn.state = NSControlStateValueOn;
|
||||
}
|
||||
viewRect.size.height += 6; // 8 - 2px padding
|
||||
v.frame = viewRect;
|
||||
return v;
|
||||
}
|
||||
|
||||
/// Loop over all subviews and find the @c NSButton that is selected.
|
||||
+ (NSInteger)radioGroupSelection:(NSView*)view {
|
||||
for (NSButton *btn in view.subviews) {
|
||||
if ([btn isKindOfClass:[NSButton class]] && btn.state == NSControlStateValueOn) {
|
||||
return btn.tag;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// @return New view with @c NSTextField label in the top left corner and @c radioView on the right side.
|
||||
+ (NSView*)viewByPrependingLabel:(NSString*)str toView:(NSView*)radioView {
|
||||
NSTextField *label = [NSTextField textFieldWithString:str];
|
||||
label.editable = NO;
|
||||
label.selectable = NO;
|
||||
label.bezeled = NO;
|
||||
label.drawsBackground = NO;
|
||||
|
||||
NSRect fL = label.frame;
|
||||
NSRect fR = radioView.frame;
|
||||
fL.origin.y += fR.size.height - fL.size.height - 8;
|
||||
fR.origin.x += fL.size.width;
|
||||
label.frame = fL;
|
||||
radioView.frame = fR;
|
||||
|
||||
NSView *view = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, NSMaxX(fR), NSMaxY(fR))];
|
||||
[view addSubview:label];
|
||||
[view addSubview:radioView];
|
||||
return view;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -26,6 +26,7 @@
|
||||
#import "ModalFeedEdit.h"
|
||||
#import "Feed+Ext.h"
|
||||
#import "FeedGroup+Ext.h"
|
||||
#import "OpmlExport.h"
|
||||
|
||||
@interface SettingsFeeds ()
|
||||
@property (weak) IBOutlet NSOutlineView *outlineView;
|
||||
@@ -51,27 +52,8 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
||||
|
||||
self.dataStore.managedObjectContext = [StoreCoordinator createChildContext];
|
||||
self.dataStore.managedObjectContext.undoManager = self.undoManager;
|
||||
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(faviconDownloadFinished:) name:kNotificationFaviconDownloadFinished object:nil];
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
}
|
||||
|
||||
/**
|
||||
Called when the backgroud download of a favicon finished.
|
||||
Notification object contains the updated @c Feed (object id).
|
||||
*/
|
||||
- (void)faviconDownloadFinished:(NSNotification*)notify {
|
||||
if ([notify.object isKindOfClass:[NSManagedObjectID class]]) {
|
||||
// TODO: Bug: Freshly ownloaded images are deleted on undo. Remove delete cascade rule?
|
||||
NSManagedObject *mo = [self.dataStore.managedObjectContext objectWithID:notify.object];
|
||||
if (!mo) return;
|
||||
[self.dataStore.managedObjectContext refreshObject:mo mergeChanges:YES];
|
||||
[self.dataStore rearrangeObjects];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - UI Button Interaction
|
||||
|
||||
@@ -112,6 +94,24 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
||||
[self showModalForFeedGroup:fg isGroupEdit:YES]; // yes will be overwritten anyway
|
||||
}
|
||||
|
||||
- (IBAction)shareMenu:(NSButton*)sender {
|
||||
if (!sender.menu) {
|
||||
sender.menu = [[NSMenu alloc] initWithTitle:NSLocalizedString(@"Import / Export menu", nil)];
|
||||
sender.menu.autoenablesItems = NO;
|
||||
[sender.menu addItemWithTitle:NSLocalizedString(@"Import Feeds …", nil) action:nil keyEquivalent:@""].tag = 101;
|
||||
[sender.menu addItemWithTitle:NSLocalizedString(@"Export Feeds …", nil) action:nil keyEquivalent:@""].tag = 102;
|
||||
// TODO: Add menus for online sync? email export? etc.
|
||||
}
|
||||
if ([sender.menu popUpMenuPositioningItem:nil atLocation:NSMakePoint(0,sender.frame.size.height) inView:sender]) {
|
||||
NSInteger tag = sender.menu.highlightedItem.tag;
|
||||
if (tag == 101) {
|
||||
[OpmlExport showImportDialog:self.view.window withTreeController:self.dataStore];
|
||||
} else if (tag == 102) {
|
||||
[OpmlExport showExportDialog:self.view.window withContext:self.dataStore.managedObjectContext];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Insert & Edit Feed Items / Modal Dialog
|
||||
|
||||
@@ -129,13 +129,13 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
||||
@param flag If @c YES open group edit modal dialog. If @c NO open feed edit modal dialog.
|
||||
*/
|
||||
- (void)showModalForFeedGroup:(FeedGroup*)fg isGroupEdit:(BOOL)flag {
|
||||
if (fg.typ == SEPARATOR) return;
|
||||
if (fg.type == SEPARATOR) return;
|
||||
[self.undoManager beginUndoGrouping];
|
||||
if (!fg || ![fg isKindOfClass:[FeedGroup class]]) {
|
||||
fg = [self insertFeedGroupAtSelection:(flag ? GROUP : FEED)];
|
||||
}
|
||||
|
||||
ModalEditDialog *editDialog = (fg.typ == GROUP ? [ModalGroupEdit modalWith:fg] : [ModalFeedEdit modalWith:fg]);
|
||||
ModalEditDialog *editDialog = (fg.type == GROUP ? [ModalGroupEdit modalWith:fg] : [ModalFeedEdit modalWith:fg]);
|
||||
|
||||
[self.view.window beginSheet:[editDialog getModalSheet] completionHandler:^(NSModalResponse returnCode) {
|
||||
if (returnCode == NSModalResponseOK) {
|
||||
@@ -275,8 +275,8 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
||||
/// Populate @c NSOutlineView data cells with core data object values.
|
||||
- (NSView *)outlineView:(NSOutlineView *)outlineView viewForTableColumn:(NSTableColumn *)tableColumn item:(id)item {
|
||||
FeedGroup *fg = [(NSTreeNode*)item representedObject];
|
||||
BOOL isFeed = (fg.typ == FEED);
|
||||
BOOL isSeperator = (fg.typ == SEPARATOR);
|
||||
BOOL isFeed = (fg.type == FEED);
|
||||
BOOL isSeperator = (fg.type == SEPARATOR);
|
||||
BOOL isRefreshColumn = [tableColumn.identifier isEqualToString:@"RefreshColumn"];
|
||||
BOOL refreshDisabled = (!isFeed || fg.refreshStr.length == 0 || [fg.refreshStr characterAtIndex:0] == '0');
|
||||
|
||||
@@ -290,7 +290,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
||||
return cellView; // the refresh cell is already skipped with the above if condition
|
||||
} else {
|
||||
cellView.textField.objectValue = fg.name;
|
||||
cellView.imageView.image = (fg.typ == GROUP ? [NSImage imageNamed:NSImageNameFolder] : [fg.feed iconImage16]);
|
||||
cellView.imageView.image = (fg.type == GROUP ? [NSImage imageNamed:NSImageNameFolder] : [fg.feed iconImage16]);
|
||||
}
|
||||
// also for refresh column
|
||||
cellView.textField.textColor = (isFeed && refreshDisabled ? [NSColor disabledControlTextColor] : [NSColor controlTextColor]);
|
||||
@@ -303,8 +303,8 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
||||
|
||||
/// Returning @c NO will result in a Action-Not-Available-Buzzer sound
|
||||
- (BOOL)respondsToSelector:(SEL)aSelector {
|
||||
if (aSelector == @selector(undo:)) return [self.undoManager canUndo];
|
||||
if (aSelector == @selector(redo:)) return [self.undoManager canRedo];
|
||||
if (aSelector == @selector(undo:)) return [self.undoManager canUndo] && self.undoManager.groupingLevel == 0;
|
||||
if (aSelector == @selector(redo:)) return [self.undoManager canRedo] && self.undoManager.groupingLevel == 0;
|
||||
if (aSelector == @selector(copy:) || aSelector == @selector(enterPressed:)) {
|
||||
BOOL outlineHasFocus = [[self.view.window firstResponder] isKindOfClass:[NSOutlineView class]];
|
||||
BOOL hasSelection = (self.dataStore.selectedNodes.count > 0);
|
||||
@@ -313,7 +313,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
||||
if (aSelector == @selector(copy:))
|
||||
return YES;
|
||||
// can edit only if selection is not a separator
|
||||
return (((FeedGroup*)self.dataStore.selectedNodes.firstObject.representedObject).typ != SEPARATOR);
|
||||
return (((FeedGroup*)self.dataStore.selectedNodes.firstObject.representedObject).type != SEPARATOR);
|
||||
}
|
||||
return [super respondsToSelector:aSelector];
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="14113" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="14313.18" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<dependencies>
|
||||
<deployment identifier="macosx"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="14113"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="14313.18"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
@@ -208,16 +208,19 @@ CA
|
||||
<binding destination="JPf-gH-wxm" name="enabled" keyPath="canInsert" id="JCF-ey-YUL"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button hidden="YES" 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"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMinX="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">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
</buttonCell>
|
||||
<connections>
|
||||
<action selector="shareMenu:" target="-2" id="JJq-7D-Bti"/>
|
||||
</connections>
|
||||
</button>
|
||||
</subviews>
|
||||
<point key="canvasLocation" x="27" y="883"/>
|
||||
<point key="canvasLocation" x="27" y="882.5"/>
|
||||
</customView>
|
||||
<viewController id="TaZ-4L-TdU" customClass="ModalFeedEdit"/>
|
||||
</objects>
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
@interface ModalSheet : NSPanel
|
||||
@property (readonly) BOOL closeInitiated;
|
||||
@property (readonly) BOOL didCloseAndSave;
|
||||
@property (readonly) BOOL didCloseAndCancel;
|
||||
|
||||
+ (instancetype)modalWithView:(NSView*)content;
|
||||
- (void)setDoneEnabled:(BOOL)accept;
|
||||
|
||||
@@ -27,12 +27,12 @@
|
||||
@end
|
||||
|
||||
@implementation ModalSheet
|
||||
@synthesize closeInitiated = _closeInitiated;
|
||||
@synthesize didCloseAndSave = _didCloseAndSave, didCloseAndCancel = _didCloseAndCancel;
|
||||
|
||||
/// User did click the 'Done' button.
|
||||
- (void)didTapDoneButton:(id)sender { [self closeWithResponse:NSModalResponseOK]; }
|
||||
/// User did click the 'Cancel' button.
|
||||
- (void)didTapCancelButton:(id)sender { [self closeWithResponse:NSModalResponseAbort]; }
|
||||
- (void)didTapCancelButton:(id)sender { [self closeWithResponse:NSModalResponseCancel]; }
|
||||
/// Manually disable 'Done' button if a task is still running.
|
||||
- (void)setDoneEnabled:(BOOL)accept { self.btnDone.enabled = accept; }
|
||||
|
||||
@@ -42,7 +42,8 @@
|
||||
And removes all subviews (clean up).
|
||||
*/
|
||||
- (void)closeWithResponse:(NSModalResponse)response {
|
||||
_closeInitiated = YES;
|
||||
_didCloseAndSave = (response == NSModalResponseOK);
|
||||
_didCloseAndCancel = (response != NSModalResponseOK);
|
||||
// store modal view width and remove subviews to avoid _NSKeyboardFocusClipView issues
|
||||
// first object is always the view of the modal dialog
|
||||
CGFloat w = self.contentView.subviews.firstObject.frame.size.width;
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
||||
for (NSManagedObjectID *oid in notify.object) {
|
||||
Feed *feed = [moc objectWithID:oid];
|
||||
if (!feed) continue;
|
||||
NSMenu *menu = [self fixUnreadCountForSubmenus:feed];
|
||||
if (!menu || menu.numberOfItems > 0)
|
||||
[self rebuiltFeedArticle:feed inMenu:menu]; // deepest menu level, feed items
|
||||
@@ -216,7 +217,7 @@
|
||||
id obj = [self.readContext objectWithID:[self.objectIDsForMenu objectAtIndex:(NSUInteger)index]];
|
||||
if ([obj isKindOfClass:[FeedGroup class]]) {
|
||||
[item setFeedGroup:obj];
|
||||
if ([(FeedGroup*)obj typ] == FEED)
|
||||
if ([(FeedGroup*)obj type] == FEED)
|
||||
[item setTarget:self action:@selector(openFeedURL:)];
|
||||
} else if ([obj isKindOfClass:[FeedArticle class]]) {
|
||||
[item setFeedArticle:obj];
|
||||
|
||||
@@ -87,12 +87,16 @@ typedef NS_ENUM(char, DisplaySetting) {
|
||||
*/
|
||||
- (NSInteger)setTitleAndUnreadCount:(FeedGroup*)fg {
|
||||
NSInteger uCount = 0;
|
||||
if (fg.typ == FEED && [UserPrefs defaultYES:@"feedUnreadCount"]) {
|
||||
if (fg.type == FEED && [UserPrefs defaultYES:@"feedUnreadCount"]) {
|
||||
uCount = fg.feed.unreadCount;
|
||||
} else if (fg.typ == GROUP && [UserPrefs defaultYES:@"groupUnreadCount"]) {
|
||||
} else if (fg.type == GROUP && [UserPrefs defaultYES:@"groupUnreadCount"]) {
|
||||
uCount = [self.submenu coreDataUnreadCount];
|
||||
}
|
||||
self.title = (uCount > 0 ? [NSString stringWithFormat:@"%@ (%ld)", fg.name, uCount] : fg.name);
|
||||
if (uCount > 0) {
|
||||
self.title = [NSString stringWithFormat:@"%@ (%ld)", fg.name, uCount];
|
||||
} else {
|
||||
self.title = (fg.name ? fg.name : @"(error)");
|
||||
}
|
||||
return uCount;
|
||||
}
|
||||
|
||||
@@ -101,12 +105,12 @@ typedef NS_ENUM(char, DisplaySetting) {
|
||||
*/
|
||||
- (void)setFeedGroup:(FeedGroup*)fg {
|
||||
self.representedObject = fg.objectID;
|
||||
if (fg.typ == SEPARATOR) {
|
||||
if (fg.type == SEPARATOR) {
|
||||
self.title = kSeparatorItemTitle;
|
||||
} else {
|
||||
self.submenu = [self.menu submenuWithIndex:fg.sortIndex isFeed:(fg.typ == FEED)];
|
||||
self.submenu = [self.menu submenuWithIndex:fg.sortIndex isFeed:(fg.type == FEED)];
|
||||
[self setTitleAndUnreadCount:fg]; // after submenu is set
|
||||
if (fg.typ == FEED) {
|
||||
if (fg.type == FEED) {
|
||||
self.tag = ScopeFeed;
|
||||
self.toolTip = fg.feed.subtitle;
|
||||
self.enabled = (fg.feed.articles.count > 0);
|
||||
|
||||
@@ -30,10 +30,14 @@
|
||||
// Feed update
|
||||
+ (NSArray<Feed*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc;
|
||||
+ (NSDate*)nextScheduledUpdate;
|
||||
// Feed display
|
||||
// Main menu display
|
||||
+ (NSInteger)unreadCountForIndexPathString:(NSString*)str;
|
||||
+ (NSArray*)sortedObjectIDsForParent:(id)parent isFeed:(BOOL)flag inContext:(NSManagedObjectContext*)moc;
|
||||
// OPML import & export
|
||||
+ (NSInteger)numberRootItemsInContext:(NSManagedObjectContext*)moc;
|
||||
+ (NSArray<FeedGroup*>*)sortedListOfRootObjectsInContext:(NSManagedObjectContext*)moc;
|
||||
// Restore sound state
|
||||
+ (void)deleteUnreferencedFeeds;
|
||||
+ (void)restoreFeedCountsAndIndexPaths;
|
||||
+ (NSArray<Feed*>*)listOfMissingFeedsInContext:(NSManagedObjectContext*)moc;
|
||||
@end
|
||||
|
||||
@@ -28,18 +28,14 @@
|
||||
|
||||
@implementation StoreCoordinator
|
||||
|
||||
#pragma mark - Managing contexts -
|
||||
#pragma mark - Managing contexts
|
||||
|
||||
/**
|
||||
@return The application main persistent context.
|
||||
*/
|
||||
/// @return The application main persistent context.
|
||||
+ (NSManagedObjectContext*)getMainContext {
|
||||
return [(AppHook*)NSApp persistentContainer].viewContext;
|
||||
}
|
||||
|
||||
/**
|
||||
New child context with @c NSMainQueueConcurrencyType and without undo manager.
|
||||
*/
|
||||
/// New child context with @c NSMainQueueConcurrencyType and without undo manager.
|
||||
+ (NSManagedObjectContext*)createChildContext {
|
||||
NSManagedObjectContext *context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType];
|
||||
[context setParentContext:[self getMainContext]];
|
||||
@@ -51,7 +47,7 @@
|
||||
/**
|
||||
Commit changes and perform save operation on @c context.
|
||||
|
||||
@param flag If @c YES save any parent context (recursive).
|
||||
@param flag If @c YES save any parent context as well (recursive).
|
||||
*/
|
||||
+ (void)saveContext:(NSManagedObjectContext*)context andParent:(BOOL)flag {
|
||||
// Performs the save action for the application, which is to send the save: message to the application's managed object context. Any encountered errors are presented to the user.
|
||||
@@ -68,7 +64,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Feed Update -
|
||||
|
||||
#pragma mark - Helper
|
||||
|
||||
/// Perform fetch and return result. If an error occurs, print it to the console.
|
||||
+ (NSArray*)fetchAllRows:(NSFetchRequest*)req inContext:(NSManagedObjectContext*)moc {
|
||||
NSError *err;
|
||||
NSArray *fetchResults = [moc executeFetchRequest:req error:&err];
|
||||
if (err) NSLog(@"ERROR: Fetch request failed: %@", err);
|
||||
//NSLog(@"%@ ==> %@", req, fetchResults); // debugging
|
||||
return fetchResults;
|
||||
}
|
||||
|
||||
/// Perform aggregated fetch where result is a single row. Use convenient methods @c fetchDate: or @c fetchInteger:.
|
||||
+ (id)fetchSingleRow:(NSManagedObjectContext*)moc request:(NSFetchRequest*)req expression:(NSExpression*)exp resultType:(NSAttributeType)type {
|
||||
NSExpressionDescription *expDesc = [[NSExpressionDescription alloc] init];
|
||||
[expDesc setName:@"singleRowAttribute"];
|
||||
[expDesc setExpression:exp];
|
||||
[expDesc setExpressionResultType:type];
|
||||
[req setResultType:NSDictionaryResultType];
|
||||
[req setPropertiesToFetch:@[expDesc]];
|
||||
return [self fetchAllRows:req inContext:moc].firstObject[@"singleRowAttribute"];
|
||||
}
|
||||
|
||||
/// Convenient method on @c fetchSingleRow: with @c NSDate return type. May be @c nil.
|
||||
+ (NSDate*)fetchDate:(NSManagedObjectContext*)moc request:(NSFetchRequest*)req expression:(NSExpression*)exp {
|
||||
return [self fetchSingleRow:moc request:req expression:exp resultType:NSDateAttributeType]; // can be nil
|
||||
}
|
||||
|
||||
/// Convenient method on @c fetchSingleRow: with @c NSInteger return type.
|
||||
+ (NSInteger)fetchInteger:(NSManagedObjectContext*)moc request:(NSFetchRequest*)req expression:(NSExpression*)exp {
|
||||
return [[self fetchSingleRow:moc request:req expression:exp resultType:NSInteger32AttributeType] integerValue];
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Feed Update
|
||||
|
||||
/**
|
||||
List of @c Feed items that need to be updated. Scheduled time is now (or in past).
|
||||
@@ -78,39 +108,23 @@
|
||||
+ (NSArray<Feed*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc {
|
||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
|
||||
if (!forceAll) {
|
||||
// when fetching also get those feeds that would need update soon (now + 30s)
|
||||
fr.predicate = [NSPredicate predicateWithFormat:@"meta.scheduled <= %@", [NSDate dateWithTimeIntervalSinceNow:+30]];
|
||||
// when fetching also get those feeds that would need update soon (now + 10s)
|
||||
fr.predicate = [NSPredicate predicateWithFormat:@"meta.scheduled <= %@", [NSDate dateWithTimeIntervalSinceNow:+10]];
|
||||
}
|
||||
NSError *err;
|
||||
NSArray *result = [moc executeFetchRequest:fr error:&err];
|
||||
if (err) NSLog(@"%@", err);
|
||||
return result;
|
||||
return [self fetchAllRows:fr inContext:moc];
|
||||
}
|
||||
|
||||
/**
|
||||
@return @c NSDate of next (earliest) feed update. May be @c nil.
|
||||
*/
|
||||
/// @return @c NSDate of next (earliest) feed update. May be @c nil.
|
||||
+ (NSDate*)nextScheduledUpdate {
|
||||
// Always get context first, or 'FeedMeta.entity.name' may not be available on app start
|
||||
NSManagedObjectContext *moc = [self getMainContext];
|
||||
NSExpression *exp = [NSExpression expressionForFunction:@"min:"
|
||||
arguments:@[[NSExpression expressionForKeyPath:@"scheduled"]]];
|
||||
NSExpressionDescription *expDesc = [[NSExpressionDescription alloc] init];
|
||||
[expDesc setName:@"earliestDate"];
|
||||
[expDesc setExpression:exp];
|
||||
[expDesc setExpressionResultType:NSDateAttributeType];
|
||||
|
||||
NSExpression *exp = [NSExpression expressionForFunction:@"min:" arguments:@[[NSExpression expressionForKeyPath:@"scheduled"]]];
|
||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedMeta.entity.name];
|
||||
[fr setResultType:NSDictionaryResultType];
|
||||
[fr setPropertiesToFetch:@[expDesc]];
|
||||
|
||||
NSError *err;
|
||||
NSArray *fetchResults = [moc executeFetchRequest:fr error:&err];
|
||||
if (err) NSLog(@"%@", err);
|
||||
return fetchResults.firstObject[@"earliestDate"]; // can be nil
|
||||
return [self fetchDate:moc request:fr expression:exp];
|
||||
}
|
||||
|
||||
#pragma mark - Feed Display -
|
||||
|
||||
#pragma mark - Main Menu Display
|
||||
|
||||
/**
|
||||
Perform core data fetch request with sum over all unread feeds matching @c str.
|
||||
@@ -120,23 +134,11 @@
|
||||
+ (NSInteger)unreadCountForIndexPathString:(NSString*)str {
|
||||
// Always get context first, or 'Feed.entity.name' may not be available on app start
|
||||
NSManagedObjectContext *moc = [self getMainContext];
|
||||
NSExpression *exp = [NSExpression expressionForFunction:@"sum:"
|
||||
arguments:@[[NSExpression expressionForKeyPath:@"unreadCount"]]];
|
||||
NSExpressionDescription *expDesc = [[NSExpressionDescription alloc] init];
|
||||
[expDesc setName:@"totalUnread"];
|
||||
[expDesc setExpression:exp];
|
||||
[expDesc setExpressionResultType:NSInteger32AttributeType];
|
||||
|
||||
NSExpression *exp = [NSExpression expressionForFunction:@"sum:" arguments:@[[NSExpression expressionForKeyPath:@"unreadCount"]]];
|
||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
|
||||
if (str && str.length > 0)
|
||||
fr.predicate = [NSPredicate predicateWithFormat:@"indexPath BEGINSWITH %@", str];
|
||||
[fr setResultType:NSDictionaryResultType];
|
||||
[fr setPropertiesToFetch:@[expDesc]];
|
||||
|
||||
NSError *err;
|
||||
NSArray *fetchResults = [moc executeFetchRequest:fr error:&err];
|
||||
if (err) NSLog(@"%@", err);
|
||||
return [fetchResults.firstObject[@"totalUnread"] integerValue];
|
||||
return [self fetchInteger:moc request:fr expression:exp];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,19 +148,34 @@
|
||||
@param flag If @c YES request list of @c FeedArticle instead of @c FeedGroup
|
||||
*/
|
||||
+ (NSArray*)sortedObjectIDsForParent:(id)parent isFeed:(BOOL)flag inContext:(NSManagedObjectContext*)moc {
|
||||
// NSManagedObjectContext *moc = [self getMainContext];
|
||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: (flag ? FeedArticle.entity : FeedGroup.entity).name];
|
||||
fr.predicate = [NSPredicate predicateWithFormat:(flag ? @"feed.group = %@" : @"parent = %@"), parent];
|
||||
fr.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:!flag]];
|
||||
[fr setResultType:NSManagedObjectIDResultType];
|
||||
|
||||
NSError *err;
|
||||
NSArray *fetchResults = [moc executeFetchRequest:fr error:&err];
|
||||
if (err) NSLog(@"%@", err);
|
||||
return fetchResults;
|
||||
[fr setResultType:NSManagedObjectIDResultType]; // only get ids
|
||||
return [self fetchAllRows:fr inContext:moc];
|
||||
}
|
||||
|
||||
#pragma mark - Restore Sound State -
|
||||
|
||||
#pragma mark - OPML Import & Export
|
||||
|
||||
/// @return Count of objects at root level. Also the @c sortIndex for the next item.
|
||||
+ (NSInteger)numberRootItemsInContext:(NSManagedObjectContext*)moc {
|
||||
NSExpression *exp = [NSExpression expressionForFunction:@"count:" arguments:@[[NSExpression expressionForEvaluatedObject]]];
|
||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedGroup.entity.name];
|
||||
fr.predicate = [NSPredicate predicateWithFormat:@"parent = NULL"];
|
||||
return [self fetchInteger:moc request:fr expression:exp];
|
||||
}
|
||||
|
||||
/// @return Sorted list of root element objects.
|
||||
+ (NSArray<FeedGroup*>*)sortedListOfRootObjectsInContext:(NSManagedObjectContext*)moc {
|
||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedGroup.entity.name];
|
||||
fr.predicate = [NSPredicate predicateWithFormat:@"parent = NULL"];
|
||||
fr.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]];
|
||||
return [self fetchAllRows:fr inContext:moc];
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Restore Sound State
|
||||
|
||||
/**
|
||||
Delete all @c Feed items where @c group @c = @c NULL.
|
||||
@@ -178,9 +195,7 @@
|
||||
*/
|
||||
+ (void)restoreFeedCountsAndIndexPaths {
|
||||
NSManagedObjectContext *moc = [self getMainContext];
|
||||
NSError *err;
|
||||
NSArray *result = [moc executeFetchRequest:[NSFetchRequest fetchRequestWithEntityName: Feed.entity.name] error:&err];
|
||||
if (err) NSLog(@"%@", err);
|
||||
NSArray *result = [self fetchAllRows:[NSFetchRequest fetchRequestWithEntityName: Feed.entity.name] inContext:moc];
|
||||
[moc performBlock:^{
|
||||
for (Feed *feed in result) {
|
||||
int16_t totalCount = (int16_t)feed.articles.count;
|
||||
@@ -194,4 +209,12 @@
|
||||
}];
|
||||
}
|
||||
|
||||
/// @return All @c Feed items where @c articles.count @c == @c 0
|
||||
+ (NSArray<Feed*>*)listOfMissingFeedsInContext:(NSManagedObjectContext*)moc {
|
||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
|
||||
// More accurate but with subquery on FeedArticle: "count(articles) == 0"
|
||||
fr.predicate = [NSPredicate predicateWithFormat:@"articleCount == 0"];
|
||||
return [self fetchAllRows:fr inContext:moc];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
Reference in New Issue
Block a user