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