OPML export / import + bug fixes + Refactoring (RSXML 2.0, StoreCoordinator, Feed type)
This commit is contained in:
@@ -1 +1 @@
|
|||||||
github "relikd/RSXML" "f012a6fa3cb8882a17762d92f3c41e49abfd3985"
|
github "relikd/RSXML" "22189e65048487f31a4db7cec91a0a9a1af88140"
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
For nearly a decade I've been using the then free version of [RSS Menu](https://itunes.apple.com/us/app/rss-menu/id423069534). However, with the release of macOS Mojave, 32bit applications are no longer supported.
|
For nearly a decade I've been using the then free version of [RSS Menu](https://itunes.apple.com/us/app/rss-menu/id423069534). However, with the release of macOS Mojave, 32bit applications are no longer supported. Furthermore, the currently available version in the Mac App Store was last updated in 2014 (as of writing).
|
||||||
|
|
||||||
*baRSS* is an open source community project and will be available on the AppStore soon (hopefully); free of charge. Everything is built from the ground up with a minimal footprint in mind.
|
*baRSS* was build from scratch with a minimal footprint in mind. It will be available on the AppStore eventually. If you want a feature to be added, drop me an email or create an issue. Look at the other issues, in case somebody else already filed one similar. If you like this project and want to say thank you drop me a line (or other stuff like money). Regardless, I'll continue development as long as I'm using it on my own. Admittedly, I've invested way too much time in this project already (1200h+) …
|
||||||
|
|
||||||
|
|
||||||
Why is this project not written in Swift?
|
Why is this project not written in Swift?
|
||||||
@@ -22,7 +22,14 @@ This project uses a modified version of Brent Simmons [RSXML](https://github.com
|
|||||||
Current project state
|
Current project state
|
||||||
---------------------
|
---------------------
|
||||||
|
|
||||||
The basic functionality is there. Manually added feeds will be downloaded and stored in an SQLite database. The complete management of feeds is there (sorting, grouping, editing, deleting). The bar menu is functional too, including unread count, URL opening and display.
|
All basic functionality is there. What's missing?
|
||||||
|
|
||||||
|
- Authenticated feeds
|
||||||
|
- Online sync with other services
|
||||||
|
- Automatic feed detection (e.g., YouTube)
|
||||||
|
- Text / UI localisation
|
||||||
|
|
||||||
|
All in all, the software is in a usable state. The remaining features will be added in the coming weeks.
|
||||||
|
|
||||||
|
|
||||||
ToDo
|
ToDo
|
||||||
@@ -39,9 +46,9 @@ ToDo
|
|||||||
- [x] Make it system default application
|
- [x] Make it system default application
|
||||||
- [ ] Display license info (e.g., RSXML)
|
- [ ] Display license info (e.g., RSXML)
|
||||||
- [x] Short article names
|
- [x] Short article names
|
||||||
- [ ] Import / Export (all feeds)
|
- [x] Import / Export (all feeds)
|
||||||
- [ ] Support for `.opml` format
|
- [x] Support for `.opml` format
|
||||||
- [ ] Append or replace
|
- [x] Append or replace
|
||||||
|
|
||||||
|
|
||||||
- [x] Status menu
|
- [x] Status menu
|
||||||
@@ -79,6 +86,7 @@ ToDo
|
|||||||
- [x] Code Documentation (mostly methods)
|
- [x] Code Documentation (mostly methods)
|
||||||
- [ ] Add Sandboxing
|
- [ ] Add Sandboxing
|
||||||
- [ ] Disable Startup checkbox (or other workaround)
|
- [ ] Disable Startup checkbox (or other workaround)
|
||||||
|
- [ ] Fix nasty bug: empty feed list (initial state)
|
||||||
|
|
||||||
|
|
||||||
- [ ] Additional features
|
- [ ] Additional features
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
54E88321211B509D00064188 /* ModalFeedEdit.xib in Resources */ = {isa = PBXBuildFile; fileRef = 54E8831F211B509D00064188 /* ModalFeedEdit.xib */; };
|
54E88321211B509D00064188 /* ModalFeedEdit.xib in Resources */ = {isa = PBXBuildFile; fileRef = 54E8831F211B509D00064188 /* ModalFeedEdit.xib */; };
|
||||||
54F39C2E210BE1F700AEE730 /* DBv1.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC28221061B3B0020715F /* DBv1.xcdatamodeld */; };
|
54F39C2E210BE1F700AEE730 /* DBv1.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC28221061B3B0020715F /* DBv1.xcdatamodeld */; };
|
||||||
54F518782162CA4F00EE856C /* ServiceManagement.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 54F518772162CA4F00EE856C /* ServiceManagement.framework */; };
|
54F518782162CA4F00EE856C /* ServiceManagement.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 54F518772162CA4F00EE856C /* ServiceManagement.framework */; };
|
||||||
|
54F6025D21C1D4170006D338 /* OpmlExport.m in Sources */ = {isa = PBXBuildFile; fileRef = 54F6025C21C1D4170006D338 /* OpmlExport.m */; };
|
||||||
54FE73D021220DEC003EAC65 /* StoreCoordinator.m in Sources */ = {isa = PBXBuildFile; fileRef = 54FE73CF21220DEC003EAC65 /* StoreCoordinator.m */; };
|
54FE73D021220DEC003EAC65 /* StoreCoordinator.m in Sources */ = {isa = PBXBuildFile; fileRef = 54FE73CF21220DEC003EAC65 /* StoreCoordinator.m */; };
|
||||||
54FE73D3212316CD003EAC65 /* BarMenu.m in Sources */ = {isa = PBXBuildFile; fileRef = 54FE73D2212316CD003EAC65 /* BarMenu.m */; };
|
54FE73D3212316CD003EAC65 /* BarMenu.m in Sources */ = {isa = PBXBuildFile; fileRef = 54FE73D2212316CD003EAC65 /* BarMenu.m */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
@@ -119,6 +120,8 @@
|
|||||||
54E8831E211B509D00064188 /* ModalFeedEdit.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ModalFeedEdit.m; sourceTree = "<group>"; };
|
54E8831E211B509D00064188 /* ModalFeedEdit.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ModalFeedEdit.m; sourceTree = "<group>"; };
|
||||||
54E8831F211B509D00064188 /* ModalFeedEdit.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ModalFeedEdit.xib; sourceTree = "<group>"; };
|
54E8831F211B509D00064188 /* ModalFeedEdit.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ModalFeedEdit.xib; sourceTree = "<group>"; };
|
||||||
54F518772162CA4F00EE856C /* ServiceManagement.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ServiceManagement.framework; path = System/Library/Frameworks/ServiceManagement.framework; sourceTree = SDKROOT; };
|
54F518772162CA4F00EE856C /* ServiceManagement.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ServiceManagement.framework; path = System/Library/Frameworks/ServiceManagement.framework; sourceTree = SDKROOT; };
|
||||||
|
54F6025B21C1D4170006D338 /* OpmlExport.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OpmlExport.h; sourceTree = "<group>"; };
|
||||||
|
54F6025C21C1D4170006D338 /* OpmlExport.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OpmlExport.m; sourceTree = "<group>"; };
|
||||||
54FE73CE21220DEC003EAC65 /* StoreCoordinator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = StoreCoordinator.h; sourceTree = "<group>"; };
|
54FE73CE21220DEC003EAC65 /* StoreCoordinator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = StoreCoordinator.h; sourceTree = "<group>"; };
|
||||||
54FE73CF21220DEC003EAC65 /* StoreCoordinator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = StoreCoordinator.m; sourceTree = "<group>"; };
|
54FE73CF21220DEC003EAC65 /* StoreCoordinator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = StoreCoordinator.m; sourceTree = "<group>"; };
|
||||||
54FE73D1212316CD003EAC65 /* BarMenu.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BarMenu.h; sourceTree = "<group>"; };
|
54FE73D1212316CD003EAC65 /* BarMenu.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BarMenu.h; sourceTree = "<group>"; };
|
||||||
@@ -268,6 +271,8 @@
|
|||||||
54E8831D211B509D00064188 /* ModalFeedEdit.h */,
|
54E8831D211B509D00064188 /* ModalFeedEdit.h */,
|
||||||
54E8831E211B509D00064188 /* ModalFeedEdit.m */,
|
54E8831E211B509D00064188 /* ModalFeedEdit.m */,
|
||||||
54E8831F211B509D00064188 /* ModalFeedEdit.xib */,
|
54E8831F211B509D00064188 /* ModalFeedEdit.xib */,
|
||||||
|
54F6025B21C1D4170006D338 /* OpmlExport.h */,
|
||||||
|
54F6025C21C1D4170006D338 /* OpmlExport.m */,
|
||||||
);
|
);
|
||||||
path = "Feeds Tab";
|
path = "Feeds Tab";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -402,6 +407,7 @@
|
|||||||
544B011A2114B41200386E5C /* ModalSheet.m in Sources */,
|
544B011A2114B41200386E5C /* ModalSheet.m in Sources */,
|
||||||
54ACC29821061FBA0020715F /* Preferences.m in Sources */,
|
54ACC29821061FBA0020715F /* Preferences.m in Sources */,
|
||||||
54195886218E1BDB00581B79 /* NSMenu+Ext.m in Sources */,
|
54195886218E1BDB00581B79 /* NSMenu+Ext.m in Sources */,
|
||||||
|
54F6025D21C1D4170006D338 /* OpmlExport.m in Sources */,
|
||||||
5496B511214D6275003ED4ED /* UserPrefs.m in Sources */,
|
5496B511214D6275003ED4ED /* UserPrefs.m in Sources */,
|
||||||
546FC43D21188AD5007CC3A3 /* SettingsFeeds.m in Sources */,
|
546FC43D21188AD5007CC3A3 /* SettingsFeeds.m in Sources */,
|
||||||
54E88320211B509D00064188 /* ModalFeedEdit.m in Sources */,
|
54E88320211B509D00064188 /* ModalFeedEdit.m in Sources */,
|
||||||
|
|||||||
@@ -27,11 +27,14 @@
|
|||||||
@interface Feed (Ext)
|
@interface Feed (Ext)
|
||||||
// Generator methods / Feed update
|
// Generator methods / Feed update
|
||||||
+ (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context;
|
+ (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context;
|
||||||
|
+ (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc;
|
||||||
- (void)calculateAndSetIndexPathString;
|
- (void)calculateAndSetIndexPathString;
|
||||||
- (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag;
|
- (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag;
|
||||||
// Article properties
|
// Article properties
|
||||||
- (NSArray<FeedArticle*>*)sortedArticles;
|
- (NSArray<FeedArticle*>*)sortedArticles;
|
||||||
- (int)markAllItemsRead;
|
- (int)markAllItemsRead;
|
||||||
- (int)markAllItemsUnread;
|
- (int)markAllItemsUnread;
|
||||||
|
// Icon
|
||||||
- (NSImage*)iconImage16;
|
- (NSImage*)iconImage16;
|
||||||
|
- (BOOL)setIcon:(NSImage*)img replaceExisting:(BOOL)overwrite;
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
#import "FeedGroup+Ext.h"
|
#import "FeedGroup+Ext.h"
|
||||||
#import "FeedIcon+CoreDataClass.h"
|
#import "FeedIcon+CoreDataClass.h"
|
||||||
#import "FeedArticle+CoreDataClass.h"
|
#import "FeedArticle+CoreDataClass.h"
|
||||||
|
#import "StoreCoordinator.h"
|
||||||
|
|
||||||
#import <Cocoa/Cocoa.h>
|
#import <Cocoa/Cocoa.h>
|
||||||
#import <RSXML/RSXML.h>
|
#import <RSXML/RSXML.h>
|
||||||
@@ -40,6 +41,15 @@
|
|||||||
return feed;
|
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.
|
/// Call @c indexPathString on @c .group and update @c .indexPath if current value is different.
|
||||||
- (void)calculateAndSetIndexPathString {
|
- (void)calculateAndSetIndexPathString {
|
||||||
NSString *pthStr = [self.group indexPathString];
|
NSString *pthStr = [self.group indexPathString];
|
||||||
@@ -59,12 +69,14 @@
|
|||||||
if (![self.subtitle isEqualToString:obj.subtitle]) self.subtitle = obj.subtitle;
|
if (![self.subtitle isEqualToString:obj.subtitle]) self.subtitle = obj.subtitle;
|
||||||
if (![self.link isEqualToString:obj.link]) self.link = obj.link;
|
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;
|
int32_t unreadBefore = self.unreadCount;
|
||||||
// Add and remove articles
|
// Add and remove articles
|
||||||
NSMutableSet<NSString*> *urls = [[self.articles valueForKeyPath:@"link"] mutableCopy];
|
NSMutableSet<NSString*> *urls = [[self.articles valueForKeyPath:@"link"] mutableCopy];
|
||||||
[self addMissingArticles:obj updateLinks:urls]; // will remove links in 'urls' that should be kept
|
[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
|
// Get new total article count and post unread-count-change notification
|
||||||
int32_t totalCount = (int32_t)self.articles.count;
|
int32_t totalCount = (int32_t)self.articles.count;
|
||||||
if (self.articleCount != totalCount)
|
if (self.articleCount != totalCount)
|
||||||
@@ -144,18 +156,21 @@
|
|||||||
- (void)deleteArticlesWithLink:(NSMutableSet<NSString*>*)urls {
|
- (void)deleteArticlesWithLink:(NSMutableSet<NSString*>*)urls {
|
||||||
if (!urls || urls.count == 0)
|
if (!urls || urls.count == 0)
|
||||||
return;
|
return;
|
||||||
self.articleCount -= (int32_t)urls.count;
|
|
||||||
for (FeedArticle *fa in self.articles) {
|
for (FeedArticle *fa in self.articles) {
|
||||||
if ([urls containsObject:fa.link]) {
|
if ([urls containsObject:fa.link]) {
|
||||||
[urls removeObject:fa.link];
|
[urls removeObject:fa.link];
|
||||||
if (fa.unread)
|
if (fa.unread)
|
||||||
self.unreadCount -= 1;
|
self.unreadCount -= 1;
|
||||||
// TODO: keep unread articles?
|
// TODO: keep unread articles?
|
||||||
[fa.managedObjectContext deleteObject:fa];
|
[self.managedObjectContext deleteObject:fa];
|
||||||
if (urls.count == 0)
|
if (urls.count == 0)
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
NSSet<FeedArticle*> *delArticles = [self.managedObjectContext deletedObjects];
|
||||||
|
if (delArticles.count > 0) {
|
||||||
|
[self removeArticles:delArticles];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -217,14 +232,32 @@
|
|||||||
return newCount - oldCount;
|
return newCount - oldCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - Icon -
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@return Return @c 16x16px image. Either from core data storage or generated default RSS icon.
|
@return Return @c 16x16px image. Either from core data storage or generated default RSS icon.
|
||||||
*/
|
*/
|
||||||
- (NSImage*)iconImage16 {
|
- (NSImage*)iconImage16 {
|
||||||
NSData *imgData = self.icon.icon;
|
NSData *imgData = self.icon.icon;
|
||||||
if (imgData) {
|
if (imgData)
|
||||||
return [[NSImage alloc] initWithData:imgData];
|
{
|
||||||
} else {
|
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;
|
static NSImage *defaultRSSIcon;
|
||||||
if (!defaultRSSIcon)
|
if (!defaultRSSIcon)
|
||||||
defaultRSSIcon = [RSSIcon iconWithSize:16];
|
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
|
@end
|
||||||
|
|||||||
@@ -29,13 +29,16 @@ typedef NS_ENUM(int16_t, FeedGroupType) {
|
|||||||
GROUP = 0, FEED = 1, SEPARATOR = 2
|
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;
|
+ (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;
|
- (NSImage*)groupIconImage16;
|
||||||
// Handle children and parents
|
// Handle children and parents
|
||||||
- (NSString*)indexPathString;
|
- (NSString*)indexPathString;
|
||||||
|
- (NSArray<FeedGroup*>*)sortedChildren;
|
||||||
- (NSMutableArray<FeedGroup*>*)allParents;
|
- (NSMutableArray<FeedGroup*>*)allParents;
|
||||||
- (BOOL)iterateSorted:(BOOL)ordered overDescendantFeeds:(void(^)(Feed *feed, BOOL* cancel))block;
|
- (BOOL)iterateSorted:(BOOL)ordered overDescendantFeeds:(void(^)(Feed *feed, BOOL* cancel))block;
|
||||||
// Printing
|
// Printing
|
||||||
|
|||||||
@@ -27,25 +27,27 @@
|
|||||||
#import <Cocoa/Cocoa.h>
|
#import <Cocoa/Cocoa.h>
|
||||||
|
|
||||||
@implementation FeedGroup (Ext)
|
@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
|
/// Create new instance and set @c Feed and @c FeedMeta if group type is @c FEED
|
||||||
+ (instancetype)newGroup:(FeedGroupType)type inContext:(NSManagedObjectContext*)moc {
|
+ (instancetype)newGroup:(FeedGroupType)type inContext:(NSManagedObjectContext*)moc {
|
||||||
FeedGroup *fg = [[FeedGroup alloc] initWithEntity: FeedGroup.entity insertIntoManagedObjectContext:moc];
|
FeedGroup *fg = [[FeedGroup alloc] initWithEntity: FeedGroup.entity insertIntoManagedObjectContext:moc];
|
||||||
fg.typ = type;
|
fg.type = type;
|
||||||
if (type == FEED)
|
if (type == FEED)
|
||||||
fg.feed = [Feed newFeedAndMetaInContext:moc];
|
fg.feed = [Feed newFeedAndMetaInContext:moc];
|
||||||
return fg;
|
return fg;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set name and refreshStr attributes. @note Only values that differ will be updated.
|
- (void)setParent:(FeedGroup *)parent andSortIndex:(int32_t)sortIndex {
|
||||||
- (void)setName:(NSString*)name andRefreshString:(NSString*)refreshStr {
|
self.parent = parent;
|
||||||
if (![self.name isEqualToString: name]) self.name = name;
|
self.sortIndex = sortIndex;
|
||||||
if (![self.refreshStr isEqualToString:refreshStr]) self.refreshStr = refreshStr;
|
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.
|
/// @return Return static @c 16x16px NSImageNameFolder image.
|
||||||
@@ -112,7 +114,7 @@
|
|||||||
|
|
||||||
/// @return Simplified description of the feed object.
|
/// @return Simplified description of the feed object.
|
||||||
- (NSString*)readableDescription {
|
- (NSString*)readableDescription {
|
||||||
switch (self.typ) {
|
switch (self.type) {
|
||||||
case SEPARATOR: return @"-------------";
|
case SEPARATOR: return @"-------------";
|
||||||
case GROUP: return [NSString stringWithFormat:@"%@", self.name];
|
case GROUP: return [NSString stringWithFormat:@"%@", self.name];
|
||||||
case FEED:
|
case FEED:
|
||||||
|
|||||||
@@ -23,18 +23,19 @@
|
|||||||
#import "FeedMeta+CoreDataClass.h"
|
#import "FeedMeta+CoreDataClass.h"
|
||||||
|
|
||||||
@interface FeedMeta (Ext)
|
@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) {
|
typedef NS_ENUM(int16_t, RefreshUnitType) {
|
||||||
/// Other types: @c GROUP, @c FEED, @c SEPARATOR
|
|
||||||
RefreshUnitSeconds = 0, RefreshUnitMinutes = 1, RefreshUnitHours = 2, RefreshUnitDays = 3, RefreshUnitWeeks = 4
|
RefreshUnitSeconds = 0, RefreshUnitMinutes = 1, RefreshUnitHours = 2, RefreshUnitDays = 3, RefreshUnitWeeks = 4
|
||||||
};
|
};
|
||||||
|
|
||||||
- (void)setErrorAndPostponeSchedule;
|
- (void)setErrorAndPostponeSchedule;
|
||||||
- (void)calculateAndSetScheduled;
|
- (void)setSucessfulWithResponse:(NSHTTPURLResponse*)response;
|
||||||
|
|
||||||
|
- (void)setUrlIfChanged:(NSString*)url;
|
||||||
- (void)setEtag:(NSString*)etag modified:(NSString*)modified;
|
- (void)setEtag:(NSString*)etag modified:(NSString*)modified;
|
||||||
- (void)setEtagAndModified:(NSHTTPURLResponse*)http;
|
- (BOOL)setRefresh:(int32_t)refresh unit:(RefreshUnitType)unit;
|
||||||
- (BOOL)setURL:(NSString*)url refresh:(int32_t)refresh unit:(RefreshUnitType)unit;
|
- (BOOL)setRefreshAndUnitFromInterval:(int32_t)interval;
|
||||||
|
|
||||||
|
- (int32_t)refreshInterval;
|
||||||
- (NSString*)readableRefreshString;
|
- (NSString*)readableRefreshString;
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -21,6 +21,11 @@
|
|||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
#import "FeedMeta+Ext.h"
|
#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)
|
@implementation FeedMeta (Ext)
|
||||||
|
|
||||||
@@ -36,41 +41,70 @@
|
|||||||
NSLog(@"ERROR: Feed download failed: %@ (errorCount: %d)", self.url, n);
|
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.
|
/// Calculate date from @c refreshNum and @c refreshUnit and set as next scheduled feed update.
|
||||||
- (void)calculateAndSetScheduled {
|
- (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]);
|
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 {
|
- (void)setEtag:(NSString*)etag modified:(NSString*)modified {
|
||||||
if (![self.etag isEqualToString:etag]) self.etag = etag;
|
if (![self.etag isEqualToString:etag]) self.etag = etag;
|
||||||
if (![self.modified isEqualToString:modified]) self.modified = modified;
|
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
|
@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);
|
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.refreshNum != refresh) self.refreshNum = refresh;
|
if (self.refreshUnit != unit) self.refreshUnit = unit;
|
||||||
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;
|
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'
|
/// @return Time interval respecting the selected unit. E.g., returns @c 180 for @c '3m'
|
||||||
- (NSTimeInterval)timeInterval {
|
- (int32_t)refreshInterval {
|
||||||
static const int unit[] = {1, 60, 3600, 86400, 604800}; // smhdw
|
return self.refreshNum * RefreshUnitValues[self.refreshUnit % 5];
|
||||||
return self.refreshNum * unit[self.refreshUnit % 5];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @return Formatted string for update interval ( e.g., @c 30m or @c 12h )
|
/// @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 *kNotificationNetworkStatusChanged = @"baRSS-notification-network-status-changed";
|
||||||
static NSString *kNotificationTotalUnreadCountChanged = @"baRSS-notification-total-unread-count-changed";
|
static NSString *kNotificationTotalUnreadCountChanged = @"baRSS-notification-total-unread-count-changed";
|
||||||
static NSString *kNotificationTotalUnreadCountReset = @"baRSS-notification-total-unread-count-reset";
|
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));
|
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));}
|
//void benchmark(char *desc, dispatch_block_t b){printf("%s: %llu ns\n", desc, dispatch_benchmark(1, b));}
|
||||||
|
|||||||
@@ -35,9 +35,22 @@
|
|||||||
// Downloading
|
// Downloading
|
||||||
+ (void)newFeed:(NSString *)urlStr 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*)urlStr;
|
+ (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
|
// User interaction
|
||||||
+ (BOOL)allowNetworkConnection;
|
+ (BOOL)allowNetworkConnection;
|
||||||
+ (BOOL)isPaused;
|
+ (BOOL)isPaused;
|
||||||
+ (void)setPaused:(BOOL)flag;
|
+ (void)setPaused:(BOOL)flag;
|
||||||
@end
|
@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;
|
return;
|
||||||
NSLog(@"fired");
|
NSLog(@"fired");
|
||||||
|
|
||||||
__block NSManagedObjectContext *childContext = [StoreCoordinator createChildContext];
|
BOOL updateAll = _nextUpdateIsForced;
|
||||||
NSArray<Feed*> *list = [StoreCoordinator getListOfFeedsThatNeedUpdate:_nextUpdateIsForced inContext:childContext];
|
|
||||||
_nextUpdateIsForced = NO;
|
_nextUpdateIsForced = NO;
|
||||||
if (list.count == 0) {
|
|
||||||
NSLog(@"ERROR: Something went wrong, timer fired too early.");
|
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
||||||
[childContext reset];
|
NSArray<Feed*> *list = [StoreCoordinator getListOfFeedsThatNeedUpdate:updateAll inContext:moc];
|
||||||
childContext = nil;
|
NSAssert(list.count > 0, @"ERROR: Something went wrong, timer fired too early.");
|
||||||
// thechnically should never happen, anyway we need to reset the timer
|
|
||||||
[self resumeUpdates];
|
[FeedDownload batchUpdateFeeds:list showErrorAlert:NO finally:^(NSArray<Feed*> *successful, NSArray<Feed*> *failed) {
|
||||||
return; // nothing to do here
|
[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();
|
return NO;
|
||||||
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];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - Download RSS Feed -
|
#pragma mark - Request Generator -
|
||||||
|
|
||||||
|
|
||||||
/// @return Base URL part. E.g., https://stackoverflow.com/a/15897956/10616114 ==> https://stackoverflow.com/
|
/// @return Base URL part. E.g., https://stackoverflow.com/a/15897956/10616114 ==> https://stackoverflow.com/
|
||||||
+ (NSURL*)hostURL:(NSString*)urlStr {
|
+ (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 {
|
+ (void)parseFeedRequest:(NSURLRequest*)request block:(nonnull void(^)(RSParsedFeed *rss, NSError *error, NSHTTPURLResponse *response))block {
|
||||||
[[[NSURLSession sharedSession] dataTaskWithRequest:[self newRequestURL:urlStr] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
|
[[[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
|
||||||
NSHTTPURLResponse* httpResponse = (NSHTTPURLResponse*)response;
|
NSHTTPURLResponse* httpResponse = (NSHTTPURLResponse*)response;
|
||||||
if (error || [httpResponse statusCode] == 304) {
|
if (error || [httpResponse statusCode] == 304) {
|
||||||
block(nil, error, httpResponse);
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
return;
|
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];
|
}] 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.
|
Start download request with existing @c Feed object. Reuses etag and modified headers.
|
||||||
|
|
||||||
@param feed @c Feed on which the update is executed.
|
@param feed @c Feed on which the update is executed.
|
||||||
@param group Mutex to count completion of all downloads.
|
@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 {
|
+ (void)downloadFeed:(Feed*)feed group:(dispatch_group_t)group
|
||||||
if (![self allowNetworkConnection])
|
errorAlert:(BOOL)alert
|
||||||
|
successful:(nonnull NSMutableArray<Feed*>*)successful
|
||||||
|
failed:(nonnull NSMutableArray<Feed*>*)failed
|
||||||
|
{
|
||||||
|
if (![self allowNetworkConnection]) {
|
||||||
|
[failed addObject:feed];
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
dispatch_group_enter(group);
|
dispatch_group_enter(group);
|
||||||
[[[NSURLSession sharedSession] dataTaskWithRequest:[self newRequest:feed.meta] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
|
[self parseFeedRequest:[self newRequest:feed.meta] block:^(RSParsedFeed *rss, NSError *error, NSHTTPURLResponse *response) {
|
||||||
NSHTTPURLResponse *header = (NSHTTPURLResponse*)response;
|
if (error) {
|
||||||
RSParsedFeed *parsed = nil; // can stay nil if !error and statusCode = 304
|
if (alert) [NSApp presentError:error];
|
||||||
BOOL hasError = (error != nil);
|
[feed.meta setErrorAndPostponeSchedule];
|
||||||
if (!error && [header statusCode] != 304) { // only parse if modified
|
[failed addObject:feed];
|
||||||
RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:header.URL.absoluteString];
|
} else {
|
||||||
// should be fine to call synchronous since dataTask is already in the background (always? proof?)
|
[feed.meta setSucessfulWithResponse:response];
|
||||||
parsed = RSParseFeedSync(xml, &error); // reuse error
|
if (rss) [feed updateWithRSS:rss postUnreadCountChange:YES];
|
||||||
if (error || !parsed || parsed.articles.count == 0) {
|
// TODO: save changes for this feed only? / Partial Update
|
||||||
hasError = YES;
|
[successful addObject:feed]; // will be added even if statusCode == 304 (rss == nil)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
[feed.managedObjectContext performBlock:^{ // otherwise access on feed will EXC_BAD_INSTRUCTION
|
dispatch_group_leave(group);
|
||||||
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];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Download feed at url and append to persistent store in root folder.
|
Download feed at url and append to persistent store in root folder.
|
||||||
On error present user modal alert.
|
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 {
|
+ (void)autoDownloadAndParseURL:(NSString*)url {
|
||||||
[FeedDownload newFeed:url block:^(RSParsedFeed *feed, NSError *error, NSHTTPURLResponse *response) {
|
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
||||||
if (error) {
|
Feed *f = [Feed appendToRootWithDefaultIntervalInContext:moc];
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
f.meta.url = url;
|
||||||
[NSApp presentError:error];
|
[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 {
|
} 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.
|
Create download list of feed URLs and download them all at once. Finally, notify when all finished.
|
||||||
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 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 rss Parsed RSS feed. If @c @c nil no feed object will be added.
|
@param block Called after all downloads finished @b OR if list is empty (in that case both parameters are @c nil ).
|
||||||
@param response May be @c nil but then feed download URL will not be set.
|
|
||||||
*/
|
*/
|
||||||
+ (void)autoParseFeedAndAppendToRoot:(nonnull RSParsedFeed*)rss response:(NSHTTPURLResponse*)response {
|
+ (void)batchUpdateFeeds:(NSArray<Feed*> *)list showErrorAlert:(BOOL)flag finally:(void(^)(NSArray<Feed*> *successful, NSArray<Feed*> *failed))block {
|
||||||
if (!rss || rss.articles.count == 0) return;
|
if (!list || list.count == 0) {
|
||||||
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
if (block) block(nil, nil);
|
||||||
NSUInteger idx = [StoreCoordinator sortedObjectIDsForParent:nil isFeed:NO inContext:moc].count;
|
return;
|
||||||
FeedGroup *newFeed = [FeedGroup newGroup:FEED inContext:moc];
|
}
|
||||||
FeedMeta *meta = newFeed.feed.meta;
|
// else, process all feed items in a batch
|
||||||
[meta setURL:response.URL.absoluteString refresh:30 unit:RefreshUnitMinutes];
|
NSMutableArray<Feed*> *successful = [NSMutableArray arrayWithCapacity:list.count];
|
||||||
[meta calculateAndSetScheduled];
|
NSMutableArray<Feed*> *failed = [NSMutableArray arrayWithCapacity:list.count];
|
||||||
[newFeed setName:rss.title andRefreshString:[meta readableRefreshString]];
|
|
||||||
[meta setEtagAndModified:response];
|
dispatch_group_t group = dispatch_group_create();
|
||||||
[newFeed.feed updateWithRSS:rss postUnreadCountChange:YES];
|
for (Feed *feed in list) {
|
||||||
newFeed.sortIndex = (int32_t)idx;
|
[self downloadFeed:feed group:group errorAlert:flag successful:successful failed:failed];
|
||||||
[newFeed.feed calculateAndSetIndexPathString];
|
}
|
||||||
[StoreCoordinator saveContext:moc andParent:YES];
|
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
|
||||||
NSString *faviconURL = newFeed.feed.link;
|
if (block) block(successful, failed);
|
||||||
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];
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download favicon located at http://.../ @c favicon.ico and rescale image to @c 16x16.
|
|
||||||
+ (NSImage*)downloadFavicon:(NSString*)urlStr {
|
#pragma mark - Favicon -
|
||||||
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) {
|
Create download list of @c favicon.ico URLs and save downloaded images to persistent store.
|
||||||
[img drawInRect:dstRect];
|
|
||||||
return YES;
|
@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 *previousURL; // check if changed and avoid multiple download
|
||||||
@property (copy) NSString *httpDate;
|
@property (copy) NSString *httpDate;
|
||||||
@property (copy) NSString *httpEtag;
|
@property (copy) NSString *httpEtag;
|
||||||
|
@property (strong) NSImage *favicon;
|
||||||
@property (strong) NSError *feedError; // download error or xml parser error
|
@property (strong) NSError *feedError; // download error or xml parser error
|
||||||
@property (strong) RSParsedFeed *feedResult; // parsed result
|
@property (strong) RSParsedFeed *feedResult; // parsed result
|
||||||
@property (assign) BOOL didDownloadFeed; // check if feed articles need update
|
@property (assign) BOOL didDownloadFeed; // check if feed articles need update
|
||||||
@@ -85,6 +86,8 @@
|
|||||||
[super viewDidLoad];
|
[super viewDidLoad];
|
||||||
self.previousURL = @"";
|
self.previousURL = @"";
|
||||||
self.refreshNum.intValue = 30;
|
self.refreshNum.intValue = 30;
|
||||||
|
self.warningIndicator.image = nil;
|
||||||
|
[self.warningIndicator.cell setHighlightsBy:NSNoCellMask];
|
||||||
[self populateTextFields:self.feedGroup];
|
[self populateTextFields:self.feedGroup];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +104,7 @@
|
|||||||
if (unit < 0 || unit > self.refreshUnit.numberOfItems - 1)
|
if (unit < 0 || unit > self.refreshUnit.numberOfItems - 1)
|
||||||
unit = self.refreshUnit.numberOfItems - 1;
|
unit = self.refreshUnit.numberOfItems - 1;
|
||||||
[self.refreshUnit selectItemAtIndex:unit];
|
[self.refreshUnit selectItemAtIndex:unit];
|
||||||
|
self.warningIndicator.image = [fg.feed iconImage16];
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - Edit Feed Data
|
#pragma mark - Edit Feed Data
|
||||||
@@ -111,31 +115,27 @@
|
|||||||
*/
|
*/
|
||||||
- (void)applyChangesToCoreDataObject {
|
- (void)applyChangesToCoreDataObject {
|
||||||
Feed *feed = self.feedGroup.feed;
|
Feed *feed = self.feedGroup.feed;
|
||||||
|
[self.feedGroup setNameIfChanged:self.name.stringValue];
|
||||||
FeedMeta *meta = feed.meta;
|
FeedMeta *meta = feed.meta;
|
||||||
BOOL intervalChanged = [meta setURL:self.previousURL refresh:self.refreshNum.intValue unit:(int16_t)self.refreshUnit.indexOfSelectedItem];
|
[meta setUrlIfChanged:self.previousURL];
|
||||||
if (intervalChanged)
|
[meta setRefresh:self.refreshNum.intValue unit:(int16_t)self.refreshUnit.indexOfSelectedItem]; // updateTimer will be scheduled once preferences is closed
|
||||||
[meta calculateAndSetScheduled]; // updateTimer will be scheduled once preferences is closed
|
|
||||||
[self.feedGroup setName:self.name.stringValue andRefreshString:[meta readableRefreshString]];
|
|
||||||
if (self.didDownloadFeed) {
|
if (self.didDownloadFeed) {
|
||||||
[meta setEtag:self.httpEtag modified:self.httpDate];
|
[meta setEtag:self.httpEtag modified:self.httpDate];
|
||||||
[feed updateWithRSS:self.feedResult postUnreadCountChange:YES];
|
[feed updateWithRSS:self.feedResult postUnreadCountChange:YES];
|
||||||
}
|
[feed setIcon:self.favicon replaceExisting:YES];
|
||||||
if (!feed.icon) {
|
|
||||||
NSString *faviconURL = feed.link;
|
|
||||||
if (faviconURL.length == 0)
|
|
||||||
faviconURL = meta.url;
|
|
||||||
[FeedDownload backgroundDownloadFavicon:faviconURL forFeed:feed];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Prepare UI (nullify @c result, @c error and start @c ProgressIndicator) and perform HTTP request.
|
Prepare UI (nullify @c result, @c error and start @c ProgressIndicator).
|
||||||
Articles will be parsed and stored in class variables.
|
Also disable 'Done' button during download and re-enable after all downloads are finished.
|
||||||
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 {
|
- (void)preDownload {
|
||||||
[self.modalSheet setDoneEnabled:NO];
|
[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.
|
// 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
|
// Reset to "" because after download it will be pre-filled with new feed title
|
||||||
if ([self.name.stringValue isEqualToString:self.feedResult.title]) {
|
if ([self.name.stringValue isEqualToString:self.feedResult.title]) {
|
||||||
@@ -145,62 +145,91 @@
|
|||||||
self.feedError = nil;
|
self.feedError = nil;
|
||||||
self.httpEtag = nil;
|
self.httpEtag = nil;
|
||||||
self.httpDate = nil;
|
self.httpDate = nil;
|
||||||
self.didDownloadFeed = NO;
|
self.favicon = nil;
|
||||||
[self.spinnerURL startAnimation:nil];
|
}
|
||||||
[self.spinnerName startAnimation: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) {
|
[FeedDownload newFeed:self.previousURL block:^(RSParsedFeed *result, NSError *error, NSHTTPURLResponse* response) {
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
if (self.modalSheet.didCloseAndCancel)
|
||||||
if (self.modalSheet.closeInitiated)
|
return;
|
||||||
return;
|
self.didDownloadFeed = YES;
|
||||||
self.didDownloadFeed = YES;
|
self.feedResult = result;
|
||||||
self.feedResult = result;
|
self.feedError = error;
|
||||||
self.feedError = error; // MAIN THREAD!: warning indicator .hidden is bound to feedError
|
self.httpEtag = [response allHeaderFields][@"Etag"];
|
||||||
self.httpEtag = [response allHeaderFields][@"Etag"];
|
self.httpDate = [response allHeaderFields][@"Date"]; // @"Expires", @"Last-Modified"
|
||||||
self.httpDate = [response allHeaderFields][@"Date"]; // @"Expires", @"Last-Modified"
|
[self postDownload:response.URL.absoluteString];
|
||||||
[self updateTextFieldURL:response.URL.absoluteString andTitle:result.title];
|
|
||||||
// TODO: play error sound?
|
|
||||||
[self.spinnerURL stopAnimation:nil];
|
|
||||||
[self.spinnerName stopAnimation:nil];
|
|
||||||
[self.modalSheet setDoneEnabled:YES];
|
|
||||||
});
|
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 {
|
Update UI TextFields with downloaded values.
|
||||||
// If URL was redirected (e.g., https redirect), replace original text field value with new one
|
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]) {
|
if (responseURL.length > 0 && ![responseURL isEqualToString:self.previousURL]) {
|
||||||
self.previousURL = responseURL;
|
self.previousURL = responseURL;
|
||||||
self.url.stringValue = responseURL;
|
self.url.stringValue = responseURL;
|
||||||
}
|
}
|
||||||
// Copy feed title to text field. (only if user hasn't set anything else yet)
|
// 3. Copy parsed feed title to text field. (only if user hasn't set anything else yet)
|
||||||
if ([self.name.stringValue isEqualToString:@""] && feedTitle.length > 0) {
|
NSString *parsedTitle = self.feedResult.title;
|
||||||
self.name.stringValue = feedTitle; // no damage to replace an empty string
|
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
|
#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.
|
/// Whenever the user finished entering the url (return key or focus change) perform a download request.
|
||||||
- (void)controlTextDidEndEditing:(NSNotification *)obj {
|
- (void)controlTextDidEndEditing:(NSNotification *)obj {
|
||||||
if (obj.object == self.url && [self urlHasChanged]) {
|
if (obj.object == self.url) {
|
||||||
if (self.modalSheet.closeInitiated)
|
if (![self.previousURL isEqualToString:self.url.stringValue]) {
|
||||||
return;
|
self.previousURL = self.url.stringValue;
|
||||||
self.previousURL = self.url.stringValue;
|
[self downloadRSS];
|
||||||
[self downloadRSS];
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,9 +273,7 @@
|
|||||||
}
|
}
|
||||||
/// Edit of group finished. Save changes to core data object and perform save operation on delegate.
|
/// Edit of group finished. Save changes to core data object and perform save operation on delegate.
|
||||||
- (void)applyChangesToCoreDataObject {
|
- (void)applyChangesToCoreDataObject {
|
||||||
NSString *name = ((NSTextField*)self.view).stringValue;
|
[self.feedGroup setNameIfChanged:((NSTextField*)self.view).stringValue];
|
||||||
if (![self.feedGroup.name isEqualToString:name])
|
|
||||||
self.feedGroup.name = name;
|
|
||||||
}
|
}
|
||||||
@end
|
@end
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="UTF-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>
|
<dependencies>
|
||||||
<deployment identifier="macosx"/>
|
<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"/>
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<objects>
|
<objects>
|
||||||
@@ -35,11 +35,11 @@
|
|||||||
</textFieldCell>
|
</textFieldCell>
|
||||||
</textField>
|
</textField>
|
||||||
<textField verticalHuggingPriority="750" fixedFrame="YES" textCompletion="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Asm-D9-ZfT">
|
<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"/>
|
<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">
|
<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"/>
|
<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"/>
|
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||||
</textFieldCell>
|
</textFieldCell>
|
||||||
<connections>
|
<connections>
|
||||||
@@ -56,11 +56,11 @@
|
|||||||
</textFieldCell>
|
</textFieldCell>
|
||||||
</textField>
|
</textField>
|
||||||
<textField verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="ab8-rr-HbK">
|
<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"/>
|
<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">
|
<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"/>
|
<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"/>
|
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||||
</textFieldCell>
|
</textFieldCell>
|
||||||
</textField>
|
</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">
|
<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"/>
|
<customFormatter key="formatter" id="Lbd-r9-4bc" customClass="StrictUIntFormatter"/>
|
||||||
<font key="font" metaFont="system"/>
|
<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"/>
|
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||||
</textFieldCell>
|
</textFieldCell>
|
||||||
</textField>
|
</textField>
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
<rect key="frame" x="304" y="31" width="16" height="16"/>
|
<rect key="frame" x="304" y="31" width="16" height="16"/>
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMinY="YES"/>
|
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMinY="YES"/>
|
||||||
</progressIndicator>
|
</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"/>
|
<rect key="frame" x="302" y="60" width="18" height="18"/>
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMinY="YES"/>
|
<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">
|
<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>
|
</buttonCell>
|
||||||
<connections>
|
<connections>
|
||||||
<action selector="didClickWarningButton:" target="-2" id="wNa-Cc-jZb"/>
|
<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>
|
</connections>
|
||||||
</button>
|
</button>
|
||||||
</subviews>
|
</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 "ModalFeedEdit.h"
|
||||||
#import "Feed+Ext.h"
|
#import "Feed+Ext.h"
|
||||||
#import "FeedGroup+Ext.h"
|
#import "FeedGroup+Ext.h"
|
||||||
|
#import "OpmlExport.h"
|
||||||
|
|
||||||
@interface SettingsFeeds ()
|
@interface SettingsFeeds ()
|
||||||
@property (weak) IBOutlet NSOutlineView *outlineView;
|
@property (weak) IBOutlet NSOutlineView *outlineView;
|
||||||
@@ -51,27 +52,8 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
|
|
||||||
self.dataStore.managedObjectContext = [StoreCoordinator createChildContext];
|
self.dataStore.managedObjectContext = [StoreCoordinator createChildContext];
|
||||||
self.dataStore.managedObjectContext.undoManager = self.undoManager;
|
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
|
#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
|
[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
|
#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.
|
@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 {
|
- (void)showModalForFeedGroup:(FeedGroup*)fg isGroupEdit:(BOOL)flag {
|
||||||
if (fg.typ == SEPARATOR) return;
|
if (fg.type == SEPARATOR) return;
|
||||||
[self.undoManager beginUndoGrouping];
|
[self.undoManager beginUndoGrouping];
|
||||||
if (!fg || ![fg isKindOfClass:[FeedGroup class]]) {
|
if (!fg || ![fg isKindOfClass:[FeedGroup class]]) {
|
||||||
fg = [self insertFeedGroupAtSelection:(flag ? GROUP : FEED)];
|
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) {
|
[self.view.window beginSheet:[editDialog getModalSheet] completionHandler:^(NSModalResponse returnCode) {
|
||||||
if (returnCode == NSModalResponseOK) {
|
if (returnCode == NSModalResponseOK) {
|
||||||
@@ -275,8 +275,8 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
/// Populate @c NSOutlineView data cells with core data object values.
|
/// Populate @c NSOutlineView data cells with core data object values.
|
||||||
- (NSView *)outlineView:(NSOutlineView *)outlineView viewForTableColumn:(NSTableColumn *)tableColumn item:(id)item {
|
- (NSView *)outlineView:(NSOutlineView *)outlineView viewForTableColumn:(NSTableColumn *)tableColumn item:(id)item {
|
||||||
FeedGroup *fg = [(NSTreeNode*)item representedObject];
|
FeedGroup *fg = [(NSTreeNode*)item representedObject];
|
||||||
BOOL isFeed = (fg.typ == FEED);
|
BOOL isFeed = (fg.type == FEED);
|
||||||
BOOL isSeperator = (fg.typ == SEPARATOR);
|
BOOL isSeperator = (fg.type == SEPARATOR);
|
||||||
BOOL isRefreshColumn = [tableColumn.identifier isEqualToString:@"RefreshColumn"];
|
BOOL isRefreshColumn = [tableColumn.identifier isEqualToString:@"RefreshColumn"];
|
||||||
BOOL refreshDisabled = (!isFeed || fg.refreshStr.length == 0 || [fg.refreshStr characterAtIndex:0] == '0');
|
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
|
return cellView; // the refresh cell is already skipped with the above if condition
|
||||||
} else {
|
} else {
|
||||||
cellView.textField.objectValue = fg.name;
|
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
|
// also for refresh column
|
||||||
cellView.textField.textColor = (isFeed && refreshDisabled ? [NSColor disabledControlTextColor] : [NSColor controlTextColor]);
|
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
|
/// Returning @c NO will result in a Action-Not-Available-Buzzer sound
|
||||||
- (BOOL)respondsToSelector:(SEL)aSelector {
|
- (BOOL)respondsToSelector:(SEL)aSelector {
|
||||||
if (aSelector == @selector(undo:)) return [self.undoManager canUndo];
|
if (aSelector == @selector(undo:)) return [self.undoManager canUndo] && self.undoManager.groupingLevel == 0;
|
||||||
if (aSelector == @selector(redo:)) return [self.undoManager canRedo];
|
if (aSelector == @selector(redo:)) return [self.undoManager canRedo] && self.undoManager.groupingLevel == 0;
|
||||||
if (aSelector == @selector(copy:) || aSelector == @selector(enterPressed:)) {
|
if (aSelector == @selector(copy:) || aSelector == @selector(enterPressed:)) {
|
||||||
BOOL outlineHasFocus = [[self.view.window firstResponder] isKindOfClass:[NSOutlineView class]];
|
BOOL outlineHasFocus = [[self.view.window firstResponder] isKindOfClass:[NSOutlineView class]];
|
||||||
BOOL hasSelection = (self.dataStore.selectedNodes.count > 0);
|
BOOL hasSelection = (self.dataStore.selectedNodes.count > 0);
|
||||||
@@ -313,7 +313,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
if (aSelector == @selector(copy:))
|
if (aSelector == @selector(copy:))
|
||||||
return YES;
|
return YES;
|
||||||
// can edit only if selection is not a separator
|
// 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];
|
return [super respondsToSelector:aSelector];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="UTF-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>
|
<dependencies>
|
||||||
<deployment identifier="macosx"/>
|
<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"/>
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<objects>
|
<objects>
|
||||||
@@ -208,16 +208,19 @@ CA
|
|||||||
<binding destination="JPf-gH-wxm" name="enabled" keyPath="canInsert" id="JCF-ey-YUL"/>
|
<binding destination="JPf-gH-wxm" name="enabled" keyPath="canInsert" id="JCF-ey-YUL"/>
|
||||||
</connections>
|
</connections>
|
||||||
</button>
|
</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"/>
|
<rect key="frame" x="295" y="-1" width="25" height="23"/>
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMaxY="YES"/>
|
<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">
|
<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"/>
|
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||||
<font key="font" metaFont="system"/>
|
<font key="font" metaFont="system"/>
|
||||||
</buttonCell>
|
</buttonCell>
|
||||||
|
<connections>
|
||||||
|
<action selector="shareMenu:" target="-2" id="JJq-7D-Bti"/>
|
||||||
|
</connections>
|
||||||
</button>
|
</button>
|
||||||
</subviews>
|
</subviews>
|
||||||
<point key="canvasLocation" x="27" y="883"/>
|
<point key="canvasLocation" x="27" y="882.5"/>
|
||||||
</customView>
|
</customView>
|
||||||
<viewController id="TaZ-4L-TdU" customClass="ModalFeedEdit"/>
|
<viewController id="TaZ-4L-TdU" customClass="ModalFeedEdit"/>
|
||||||
</objects>
|
</objects>
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
#import <Cocoa/Cocoa.h>
|
#import <Cocoa/Cocoa.h>
|
||||||
|
|
||||||
@interface ModalSheet : NSPanel
|
@interface ModalSheet : NSPanel
|
||||||
@property (readonly) BOOL closeInitiated;
|
@property (readonly) BOOL didCloseAndSave;
|
||||||
|
@property (readonly) BOOL didCloseAndCancel;
|
||||||
|
|
||||||
+ (instancetype)modalWithView:(NSView*)content;
|
+ (instancetype)modalWithView:(NSView*)content;
|
||||||
- (void)setDoneEnabled:(BOOL)accept;
|
- (void)setDoneEnabled:(BOOL)accept;
|
||||||
|
|||||||
@@ -27,12 +27,12 @@
|
|||||||
@end
|
@end
|
||||||
|
|
||||||
@implementation ModalSheet
|
@implementation ModalSheet
|
||||||
@synthesize closeInitiated = _closeInitiated;
|
@synthesize didCloseAndSave = _didCloseAndSave, didCloseAndCancel = _didCloseAndCancel;
|
||||||
|
|
||||||
/// User did click the 'Done' button.
|
/// User did click the 'Done' button.
|
||||||
- (void)didTapDoneButton:(id)sender { [self closeWithResponse:NSModalResponseOK]; }
|
- (void)didTapDoneButton:(id)sender { [self closeWithResponse:NSModalResponseOK]; }
|
||||||
/// User did click the 'Cancel' button.
|
/// 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.
|
/// Manually disable 'Done' button if a task is still running.
|
||||||
- (void)setDoneEnabled:(BOOL)accept { self.btnDone.enabled = accept; }
|
- (void)setDoneEnabled:(BOOL)accept { self.btnDone.enabled = accept; }
|
||||||
|
|
||||||
@@ -42,7 +42,8 @@
|
|||||||
And removes all subviews (clean up).
|
And removes all subviews (clean up).
|
||||||
*/
|
*/
|
||||||
- (void)closeWithResponse:(NSModalResponse)response {
|
- (void)closeWithResponse:(NSModalResponse)response {
|
||||||
_closeInitiated = YES;
|
_didCloseAndSave = (response == NSModalResponseOK);
|
||||||
|
_didCloseAndCancel = (response != NSModalResponseOK);
|
||||||
// store modal view width and remove subviews to avoid _NSKeyboardFocusClipView issues
|
// store modal view width and remove subviews to avoid _NSKeyboardFocusClipView issues
|
||||||
// first object is always the view of the modal dialog
|
// first object is always the view of the modal dialog
|
||||||
CGFloat w = self.contentView.subviews.firstObject.frame.size.width;
|
CGFloat w = self.contentView.subviews.firstObject.frame.size.width;
|
||||||
|
|||||||
@@ -127,6 +127,7 @@
|
|||||||
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
||||||
for (NSManagedObjectID *oid in notify.object) {
|
for (NSManagedObjectID *oid in notify.object) {
|
||||||
Feed *feed = [moc objectWithID:oid];
|
Feed *feed = [moc objectWithID:oid];
|
||||||
|
if (!feed) continue;
|
||||||
NSMenu *menu = [self fixUnreadCountForSubmenus:feed];
|
NSMenu *menu = [self fixUnreadCountForSubmenus:feed];
|
||||||
if (!menu || menu.numberOfItems > 0)
|
if (!menu || menu.numberOfItems > 0)
|
||||||
[self rebuiltFeedArticle:feed inMenu:menu]; // deepest menu level, feed items
|
[self rebuiltFeedArticle:feed inMenu:menu]; // deepest menu level, feed items
|
||||||
@@ -216,7 +217,7 @@
|
|||||||
id obj = [self.readContext objectWithID:[self.objectIDsForMenu objectAtIndex:(NSUInteger)index]];
|
id obj = [self.readContext objectWithID:[self.objectIDsForMenu objectAtIndex:(NSUInteger)index]];
|
||||||
if ([obj isKindOfClass:[FeedGroup class]]) {
|
if ([obj isKindOfClass:[FeedGroup class]]) {
|
||||||
[item setFeedGroup:obj];
|
[item setFeedGroup:obj];
|
||||||
if ([(FeedGroup*)obj typ] == FEED)
|
if ([(FeedGroup*)obj type] == FEED)
|
||||||
[item setTarget:self action:@selector(openFeedURL:)];
|
[item setTarget:self action:@selector(openFeedURL:)];
|
||||||
} else if ([obj isKindOfClass:[FeedArticle class]]) {
|
} else if ([obj isKindOfClass:[FeedArticle class]]) {
|
||||||
[item setFeedArticle:obj];
|
[item setFeedArticle:obj];
|
||||||
|
|||||||
@@ -87,12 +87,16 @@ typedef NS_ENUM(char, DisplaySetting) {
|
|||||||
*/
|
*/
|
||||||
- (NSInteger)setTitleAndUnreadCount:(FeedGroup*)fg {
|
- (NSInteger)setTitleAndUnreadCount:(FeedGroup*)fg {
|
||||||
NSInteger uCount = 0;
|
NSInteger uCount = 0;
|
||||||
if (fg.typ == FEED && [UserPrefs defaultYES:@"feedUnreadCount"]) {
|
if (fg.type == FEED && [UserPrefs defaultYES:@"feedUnreadCount"]) {
|
||||||
uCount = fg.feed.unreadCount;
|
uCount = fg.feed.unreadCount;
|
||||||
} else if (fg.typ == GROUP && [UserPrefs defaultYES:@"groupUnreadCount"]) {
|
} else if (fg.type == GROUP && [UserPrefs defaultYES:@"groupUnreadCount"]) {
|
||||||
uCount = [self.submenu coreDataUnreadCount];
|
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;
|
return uCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,12 +105,12 @@ typedef NS_ENUM(char, DisplaySetting) {
|
|||||||
*/
|
*/
|
||||||
- (void)setFeedGroup:(FeedGroup*)fg {
|
- (void)setFeedGroup:(FeedGroup*)fg {
|
||||||
self.representedObject = fg.objectID;
|
self.representedObject = fg.objectID;
|
||||||
if (fg.typ == SEPARATOR) {
|
if (fg.type == SEPARATOR) {
|
||||||
self.title = kSeparatorItemTitle;
|
self.title = kSeparatorItemTitle;
|
||||||
} else {
|
} 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
|
[self setTitleAndUnreadCount:fg]; // after submenu is set
|
||||||
if (fg.typ == FEED) {
|
if (fg.type == FEED) {
|
||||||
self.tag = ScopeFeed;
|
self.tag = ScopeFeed;
|
||||||
self.toolTip = fg.feed.subtitle;
|
self.toolTip = fg.feed.subtitle;
|
||||||
self.enabled = (fg.feed.articles.count > 0);
|
self.enabled = (fg.feed.articles.count > 0);
|
||||||
|
|||||||
@@ -30,10 +30,14 @@
|
|||||||
// Feed update
|
// Feed update
|
||||||
+ (NSArray<Feed*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc;
|
+ (NSArray<Feed*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc;
|
||||||
+ (NSDate*)nextScheduledUpdate;
|
+ (NSDate*)nextScheduledUpdate;
|
||||||
// Feed display
|
// Main menu display
|
||||||
+ (NSInteger)unreadCountForIndexPathString:(NSString*)str;
|
+ (NSInteger)unreadCountForIndexPathString:(NSString*)str;
|
||||||
+ (NSArray*)sortedObjectIDsForParent:(id)parent isFeed:(BOOL)flag inContext:(NSManagedObjectContext*)moc;
|
+ (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
|
// Restore sound state
|
||||||
+ (void)deleteUnreferencedFeeds;
|
+ (void)deleteUnreferencedFeeds;
|
||||||
+ (void)restoreFeedCountsAndIndexPaths;
|
+ (void)restoreFeedCountsAndIndexPaths;
|
||||||
|
+ (NSArray<Feed*>*)listOfMissingFeedsInContext:(NSManagedObjectContext*)moc;
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -28,18 +28,14 @@
|
|||||||
|
|
||||||
@implementation StoreCoordinator
|
@implementation StoreCoordinator
|
||||||
|
|
||||||
#pragma mark - Managing contexts -
|
#pragma mark - Managing contexts
|
||||||
|
|
||||||
/**
|
/// @return The application main persistent context.
|
||||||
@return The application main persistent context.
|
|
||||||
*/
|
|
||||||
+ (NSManagedObjectContext*)getMainContext {
|
+ (NSManagedObjectContext*)getMainContext {
|
||||||
return [(AppHook*)NSApp persistentContainer].viewContext;
|
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*)createChildContext {
|
||||||
NSManagedObjectContext *context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType];
|
NSManagedObjectContext *context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType];
|
||||||
[context setParentContext:[self getMainContext]];
|
[context setParentContext:[self getMainContext]];
|
||||||
@@ -51,7 +47,7 @@
|
|||||||
/**
|
/**
|
||||||
Commit changes and perform save operation on @c context.
|
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 {
|
+ (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.
|
// 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).
|
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 {
|
+ (NSArray<Feed*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc {
|
||||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
|
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
|
||||||
if (!forceAll) {
|
if (!forceAll) {
|
||||||
// when fetching also get those feeds that would need update soon (now + 30s)
|
// when fetching also get those feeds that would need update soon (now + 10s)
|
||||||
fr.predicate = [NSPredicate predicateWithFormat:@"meta.scheduled <= %@", [NSDate dateWithTimeIntervalSinceNow:+30]];
|
fr.predicate = [NSPredicate predicateWithFormat:@"meta.scheduled <= %@", [NSDate dateWithTimeIntervalSinceNow:+10]];
|
||||||
}
|
}
|
||||||
NSError *err;
|
return [self fetchAllRows:fr inContext:moc];
|
||||||
NSArray *result = [moc executeFetchRequest:fr error:&err];
|
|
||||||
if (err) NSLog(@"%@", err);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/// @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 {
|
+ (NSDate*)nextScheduledUpdate {
|
||||||
// Always get context first, or 'FeedMeta.entity.name' may not be available on app start
|
// Always get context first, or 'FeedMeta.entity.name' may not be available on app start
|
||||||
NSManagedObjectContext *moc = [self getMainContext];
|
NSManagedObjectContext *moc = [self getMainContext];
|
||||||
NSExpression *exp = [NSExpression expressionForFunction:@"min:"
|
NSExpression *exp = [NSExpression expressionForFunction:@"min:" arguments:@[[NSExpression expressionForKeyPath:@"scheduled"]]];
|
||||||
arguments:@[[NSExpression expressionForKeyPath:@"scheduled"]]];
|
|
||||||
NSExpressionDescription *expDesc = [[NSExpressionDescription alloc] init];
|
|
||||||
[expDesc setName:@"earliestDate"];
|
|
||||||
[expDesc setExpression:exp];
|
|
||||||
[expDesc setExpressionResultType:NSDateAttributeType];
|
|
||||||
|
|
||||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedMeta.entity.name];
|
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedMeta.entity.name];
|
||||||
[fr setResultType:NSDictionaryResultType];
|
return [self fetchDate:moc request:fr expression:exp];
|
||||||
[fr setPropertiesToFetch:@[expDesc]];
|
|
||||||
|
|
||||||
NSError *err;
|
|
||||||
NSArray *fetchResults = [moc executeFetchRequest:fr error:&err];
|
|
||||||
if (err) NSLog(@"%@", err);
|
|
||||||
return fetchResults.firstObject[@"earliestDate"]; // can be nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - Feed Display -
|
|
||||||
|
#pragma mark - Main Menu Display
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Perform core data fetch request with sum over all unread feeds matching @c str.
|
Perform core data fetch request with sum over all unread feeds matching @c str.
|
||||||
@@ -120,23 +134,11 @@
|
|||||||
+ (NSInteger)unreadCountForIndexPathString:(NSString*)str {
|
+ (NSInteger)unreadCountForIndexPathString:(NSString*)str {
|
||||||
// Always get context first, or 'Feed.entity.name' may not be available on app start
|
// Always get context first, or 'Feed.entity.name' may not be available on app start
|
||||||
NSManagedObjectContext *moc = [self getMainContext];
|
NSManagedObjectContext *moc = [self getMainContext];
|
||||||
NSExpression *exp = [NSExpression expressionForFunction:@"sum:"
|
NSExpression *exp = [NSExpression expressionForFunction:@"sum:" arguments:@[[NSExpression expressionForKeyPath:@"unreadCount"]]];
|
||||||
arguments:@[[NSExpression expressionForKeyPath:@"unreadCount"]]];
|
|
||||||
NSExpressionDescription *expDesc = [[NSExpressionDescription alloc] init];
|
|
||||||
[expDesc setName:@"totalUnread"];
|
|
||||||
[expDesc setExpression:exp];
|
|
||||||
[expDesc setExpressionResultType:NSInteger32AttributeType];
|
|
||||||
|
|
||||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
|
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
|
||||||
if (str && str.length > 0)
|
if (str && str.length > 0)
|
||||||
fr.predicate = [NSPredicate predicateWithFormat:@"indexPath BEGINSWITH %@", str];
|
fr.predicate = [NSPredicate predicateWithFormat:@"indexPath BEGINSWITH %@", str];
|
||||||
[fr setResultType:NSDictionaryResultType];
|
return [self fetchInteger:moc request:fr expression:exp];
|
||||||
[fr setPropertiesToFetch:@[expDesc]];
|
|
||||||
|
|
||||||
NSError *err;
|
|
||||||
NSArray *fetchResults = [moc executeFetchRequest:fr error:&err];
|
|
||||||
if (err) NSLog(@"%@", err);
|
|
||||||
return [fetchResults.firstObject[@"totalUnread"] integerValue];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,19 +148,34 @@
|
|||||||
@param flag If @c YES request list of @c FeedArticle instead of @c FeedGroup
|
@param flag If @c YES request list of @c FeedArticle instead of @c FeedGroup
|
||||||
*/
|
*/
|
||||||
+ (NSArray*)sortedObjectIDsForParent:(id)parent isFeed:(BOOL)flag inContext:(NSManagedObjectContext*)moc {
|
+ (NSArray*)sortedObjectIDsForParent:(id)parent isFeed:(BOOL)flag inContext:(NSManagedObjectContext*)moc {
|
||||||
// NSManagedObjectContext *moc = [self getMainContext];
|
|
||||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: (flag ? FeedArticle.entity : FeedGroup.entity).name];
|
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: (flag ? FeedArticle.entity : FeedGroup.entity).name];
|
||||||
fr.predicate = [NSPredicate predicateWithFormat:(flag ? @"feed.group = %@" : @"parent = %@"), parent];
|
fr.predicate = [NSPredicate predicateWithFormat:(flag ? @"feed.group = %@" : @"parent = %@"), parent];
|
||||||
fr.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:!flag]];
|
fr.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:!flag]];
|
||||||
[fr setResultType:NSManagedObjectIDResultType];
|
[fr setResultType:NSManagedObjectIDResultType]; // only get ids
|
||||||
|
return [self fetchAllRows:fr inContext:moc];
|
||||||
NSError *err;
|
|
||||||
NSArray *fetchResults = [moc executeFetchRequest:fr error:&err];
|
|
||||||
if (err) NSLog(@"%@", err);
|
|
||||||
return fetchResults;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#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.
|
Delete all @c Feed items where @c group @c = @c NULL.
|
||||||
@@ -178,9 +195,7 @@
|
|||||||
*/
|
*/
|
||||||
+ (void)restoreFeedCountsAndIndexPaths {
|
+ (void)restoreFeedCountsAndIndexPaths {
|
||||||
NSManagedObjectContext *moc = [self getMainContext];
|
NSManagedObjectContext *moc = [self getMainContext];
|
||||||
NSError *err;
|
NSArray *result = [self fetchAllRows:[NSFetchRequest fetchRequestWithEntityName: Feed.entity.name] inContext:moc];
|
||||||
NSArray *result = [moc executeFetchRequest:[NSFetchRequest fetchRequestWithEntityName: Feed.entity.name] error:&err];
|
|
||||||
if (err) NSLog(@"%@", err);
|
|
||||||
[moc performBlock:^{
|
[moc performBlock:^{
|
||||||
for (Feed *feed in result) {
|
for (Feed *feed in result) {
|
||||||
int16_t totalCount = (int16_t)feed.articles.count;
|
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
|
@end
|
||||||
|
|||||||
Reference in New Issue
Block a user