Refactoring Part 3: Feed configuration and CoreData Model
This commit is contained in:
@@ -73,10 +73,10 @@ ToDo
|
|||||||
- [x] Delete old ones eventually
|
- [x] Delete old ones eventually
|
||||||
- [x] Pause on internet connection lost
|
- [x] Pause on internet connection lost
|
||||||
- [ ] Download with ephemeral url session?
|
- [ ] Download with ephemeral url session?
|
||||||
- [ ] Purge cache
|
- [x] Purge cache
|
||||||
- [ ] Manually or automatically
|
- [x] Manually or automatically
|
||||||
- [ ] Add something to restore a broken state
|
- [x] Add something to restore a broken state
|
||||||
- [ ] Code Documentation (mostly methods)
|
- [x] Code Documentation (mostly methods)
|
||||||
- [ ] Add Sandboxing
|
- [ ] Add Sandboxing
|
||||||
- [ ] Disable Startup checkbox (or other workaround)
|
- [ ] Disable Startup checkbox (or other workaround)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
540F704521B6C16C0022E69D /* FeedMeta+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 540F704421B6C16C0022E69D /* FeedMeta+Ext.m */; };
|
||||||
54195883218A061100581B79 /* Feed+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54195882218A061100581B79 /* Feed+Ext.m */; };
|
54195883218A061100581B79 /* Feed+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54195882218A061100581B79 /* Feed+Ext.m */; };
|
||||||
54195886218E1BDB00581B79 /* NSMenu+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54195885218E1BDB00581B79 /* NSMenu+Ext.m */; };
|
54195886218E1BDB00581B79 /* NSMenu+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54195885218E1BDB00581B79 /* NSMenu+Ext.m */; };
|
||||||
54209E942117325100F3B5EF /* DrawImage.m in Sources */ = {isa = PBXBuildFile; fileRef = 54209E932117325100F3B5EF /* DrawImage.m */; };
|
54209E942117325100F3B5EF /* DrawImage.m in Sources */ = {isa = PBXBuildFile; fileRef = 54209E932117325100F3B5EF /* DrawImage.m */; };
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
546FC44321189975007CC3A3 /* SettingsGeneral.m in Sources */ = {isa = PBXBuildFile; fileRef = 546FC44121189975007CC3A3 /* SettingsGeneral.m */; };
|
546FC44321189975007CC3A3 /* SettingsGeneral.m in Sources */ = {isa = PBXBuildFile; fileRef = 546FC44121189975007CC3A3 /* SettingsGeneral.m */; };
|
||||||
546FC44421189975007CC3A3 /* SettingsGeneral.xib in Resources */ = {isa = PBXBuildFile; fileRef = 546FC44221189975007CC3A3 /* SettingsGeneral.xib */; };
|
546FC44421189975007CC3A3 /* SettingsGeneral.xib in Resources */ = {isa = PBXBuildFile; fileRef = 546FC44221189975007CC3A3 /* SettingsGeneral.xib */; };
|
||||||
546FC4472118A8E6007CC3A3 /* Preferences.xib in Resources */ = {isa = PBXBuildFile; fileRef = 546FC4462118A8E6007CC3A3 /* Preferences.xib */; };
|
546FC4472118A8E6007CC3A3 /* Preferences.xib in Resources */ = {isa = PBXBuildFile; fileRef = 546FC4462118A8E6007CC3A3 /* Preferences.xib */; };
|
||||||
5477D34E21233C62002BA27F /* FeedConfig+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 5477D34D21233C62002BA27F /* FeedConfig+Ext.m */; };
|
5477D34E21233C62002BA27F /* FeedGroup+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 5477D34D21233C62002BA27F /* FeedGroup+Ext.m */; };
|
||||||
5496B511214D6275003ED4ED /* UserPrefs.m in Sources */ = {isa = PBXBuildFile; fileRef = 5496B510214D6275003ED4ED /* UserPrefs.m */; };
|
5496B511214D6275003ED4ED /* UserPrefs.m in Sources */ = {isa = PBXBuildFile; fileRef = 5496B510214D6275003ED4ED /* UserPrefs.m */; };
|
||||||
54ACC28621061B3C0020715F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 54ACC28521061B3C0020715F /* Assets.xcassets */; };
|
54ACC28621061B3C0020715F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 54ACC28521061B3C0020715F /* Assets.xcassets */; };
|
||||||
54ACC28C21061B3C0020715F /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC28B21061B3C0020715F /* main.m */; };
|
54ACC28C21061B3C0020715F /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC28B21061B3C0020715F /* main.m */; };
|
||||||
@@ -72,6 +73,8 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
540F704321B6C16C0022E69D /* FeedMeta+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FeedMeta+Ext.h"; sourceTree = "<group>"; };
|
||||||
|
540F704421B6C16C0022E69D /* FeedMeta+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "FeedMeta+Ext.m"; sourceTree = "<group>"; };
|
||||||
54195881218A061100581B79 /* Feed+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Feed+Ext.h"; sourceTree = "<group>"; };
|
54195881218A061100581B79 /* Feed+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Feed+Ext.h"; sourceTree = "<group>"; };
|
||||||
54195882218A061100581B79 /* Feed+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "Feed+Ext.m"; sourceTree = "<group>"; };
|
54195882218A061100581B79 /* Feed+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "Feed+Ext.m"; sourceTree = "<group>"; };
|
||||||
54195884218E1BDB00581B79 /* NSMenu+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSMenu+Ext.h"; sourceTree = "<group>"; };
|
54195884218E1BDB00581B79 /* NSMenu+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSMenu+Ext.h"; sourceTree = "<group>"; };
|
||||||
@@ -94,8 +97,8 @@
|
|||||||
546FC44121189975007CC3A3 /* SettingsGeneral.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SettingsGeneral.m; sourceTree = "<group>"; };
|
546FC44121189975007CC3A3 /* SettingsGeneral.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SettingsGeneral.m; sourceTree = "<group>"; };
|
||||||
546FC44221189975007CC3A3 /* SettingsGeneral.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SettingsGeneral.xib; sourceTree = "<group>"; };
|
546FC44221189975007CC3A3 /* SettingsGeneral.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SettingsGeneral.xib; sourceTree = "<group>"; };
|
||||||
546FC4462118A8E6007CC3A3 /* Preferences.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = Preferences.xib; sourceTree = "<group>"; };
|
546FC4462118A8E6007CC3A3 /* Preferences.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = Preferences.xib; sourceTree = "<group>"; };
|
||||||
5477D34C21233C62002BA27F /* FeedConfig+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FeedConfig+Ext.h"; sourceTree = "<group>"; };
|
5477D34C21233C62002BA27F /* FeedGroup+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FeedGroup+Ext.h"; sourceTree = "<group>"; };
|
||||||
5477D34D21233C62002BA27F /* FeedConfig+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "FeedConfig+Ext.m"; sourceTree = "<group>"; };
|
5477D34D21233C62002BA27F /* FeedGroup+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "FeedGroup+Ext.m"; sourceTree = "<group>"; };
|
||||||
5496B50F214D6275003ED4ED /* UserPrefs.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UserPrefs.h; sourceTree = "<group>"; };
|
5496B50F214D6275003ED4ED /* UserPrefs.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UserPrefs.h; sourceTree = "<group>"; };
|
||||||
5496B510214D6275003ED4ED /* UserPrefs.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UserPrefs.m; sourceTree = "<group>"; };
|
5496B510214D6275003ED4ED /* UserPrefs.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UserPrefs.m; sourceTree = "<group>"; };
|
||||||
54ACC27C21061B3B0020715F /* baRSS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = baRSS.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
54ACC27C21061B3B0020715F /* baRSS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = baRSS.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
@@ -143,10 +146,12 @@
|
|||||||
54195880218A05E700581B79 /* Categories */ = {
|
54195880218A05E700581B79 /* Categories */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
5477D34C21233C62002BA27F /* FeedConfig+Ext.h */,
|
5477D34C21233C62002BA27F /* FeedGroup+Ext.h */,
|
||||||
5477D34D21233C62002BA27F /* FeedConfig+Ext.m */,
|
5477D34D21233C62002BA27F /* FeedGroup+Ext.m */,
|
||||||
54195881218A061100581B79 /* Feed+Ext.h */,
|
54195881218A061100581B79 /* Feed+Ext.h */,
|
||||||
54195882218A061100581B79 /* Feed+Ext.m */,
|
54195882218A061100581B79 /* Feed+Ext.m */,
|
||||||
|
540F704321B6C16C0022E69D /* FeedMeta+Ext.h */,
|
||||||
|
540F704421B6C16C0022E69D /* FeedMeta+Ext.m */,
|
||||||
);
|
);
|
||||||
path = Categories;
|
path = Categories;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -386,7 +391,8 @@
|
|||||||
544B011D2114EE9100386E5C /* AppHook.m in Sources */,
|
544B011D2114EE9100386E5C /* AppHook.m in Sources */,
|
||||||
546FC44321189975007CC3A3 /* SettingsGeneral.m in Sources */,
|
546FC44321189975007CC3A3 /* SettingsGeneral.m in Sources */,
|
||||||
54ACC29521061E270020715F /* FeedDownload.m in Sources */,
|
54ACC29521061E270020715F /* FeedDownload.m in Sources */,
|
||||||
5477D34E21233C62002BA27F /* FeedConfig+Ext.m in Sources */,
|
5477D34E21233C62002BA27F /* FeedGroup+Ext.m in Sources */,
|
||||||
|
540F704521B6C16C0022E69D /* FeedMeta+Ext.m in Sources */,
|
||||||
54ACC28C21061B3C0020715F /* main.m in Sources */,
|
54ACC28C21061B3C0020715F /* main.m in Sources */,
|
||||||
54FE73D3212316CD003EAC65 /* BarMenu.m in Sources */,
|
54FE73D3212316CD003EAC65 /* BarMenu.m in Sources */,
|
||||||
544B011A2114B41200386E5C /* ModalSheet.m in Sources */,
|
544B011A2114B41200386E5C /* ModalSheet.m in Sources */,
|
||||||
|
|||||||
@@ -25,9 +25,12 @@
|
|||||||
@class RSParsedFeed;
|
@class RSParsedFeed;
|
||||||
|
|
||||||
@interface Feed (Ext)
|
@interface Feed (Ext)
|
||||||
- (void)updateWithRSS:(RSParsedFeed*)obj;
|
// Generator methods / Feed update
|
||||||
- (NSArray<FeedItem*>*)sortedArticles;
|
+ (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context;
|
||||||
|
- (void)calculateAndSetIndexPathString;
|
||||||
|
- (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag;
|
||||||
|
// Article properties
|
||||||
|
- (NSArray<FeedArticle*>*)sortedArticles;
|
||||||
- (int)markAllItemsRead;
|
- (int)markAllItemsRead;
|
||||||
- (int)markAllItemsUnread;
|
- (int)markAllItemsUnread;
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -21,23 +21,48 @@
|
|||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
#import "Feed+Ext.h"
|
#import "Feed+Ext.h"
|
||||||
#import "FeedConfig+Ext.h"
|
#import "FeedMeta+Ext.h"
|
||||||
#import "FeedItem+CoreDataClass.h"
|
#import "FeedGroup+Ext.h"
|
||||||
|
#import "FeedArticle+CoreDataClass.h"
|
||||||
|
#import "Constants.h"
|
||||||
|
|
||||||
#import <RSXML/RSXML.h>
|
#import <RSXML/RSXML.h>
|
||||||
|
|
||||||
@implementation Feed (Ext)
|
@implementation Feed (Ext)
|
||||||
|
|
||||||
|
/// Instantiates new @c Feed and @c FeedMeta entities in context.
|
||||||
|
+ (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)moc {
|
||||||
|
Feed *feed = [[Feed alloc] initWithEntity:Feed.entity insertIntoManagedObjectContext:moc];
|
||||||
|
feed.meta = [[FeedMeta alloc] initWithEntity:FeedMeta.entity insertIntoManagedObjectContext:moc];
|
||||||
|
return feed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call @c indexPathString on @c .group and update @c .indexPath if current value is different.
|
||||||
|
- (void)calculateAndSetIndexPathString {
|
||||||
|
NSString *pthStr = [self.group indexPathString];
|
||||||
|
if (![self.indexPath isEqualToString:pthStr])
|
||||||
|
self.indexPath = pthStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Update Feed Items -
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Replace feed title, subtitle and link (if changed). Also adds new articles and removes old ones.
|
Replace feed title, subtitle and link (if changed). Also adds new articles and removes old ones.
|
||||||
*/
|
*/
|
||||||
- (void)updateWithRSS:(RSParsedFeed*)obj {
|
- (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag {
|
||||||
if (![self.title isEqualToString:obj.title]) self.title = obj.title;
|
if (![self.title isEqualToString:obj.title]) self.title = obj.title;
|
||||||
if (![self.subtitle isEqualToString:obj.subtitle]) self.subtitle = obj.subtitle;
|
if (![self.subtitle isEqualToString:obj.subtitle]) self.subtitle = obj.subtitle;
|
||||||
if (![self.link isEqualToString:obj.link]) self.link = obj.link;
|
if (![self.link isEqualToString:obj.link]) self.link = obj.link;
|
||||||
|
|
||||||
NSMutableSet<NSString*> *urls = [[self.items valueForKeyPath:@"link"] mutableCopy];
|
int32_t unreadBefore = self.unreadCount;
|
||||||
if ([self addMissingArticles:obj updateLinks:urls]) // will remove links in 'urls' that should be kept
|
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
|
||||||
|
if (flag) {
|
||||||
|
NSNumber *cDiff = [NSNumber numberWithInteger:self.unreadCount - unreadBefore];
|
||||||
|
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged object:cDiff];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,7 +72,7 @@
|
|||||||
@return @c YES if new items were added, @c NO otherwise.
|
@return @c YES if new items were added, @c NO otherwise.
|
||||||
*/
|
*/
|
||||||
- (BOOL)addMissingArticles:(RSParsedFeed*)obj updateLinks:(NSMutableSet<NSString*>*)urls {
|
- (BOOL)addMissingArticles:(RSParsedFeed*)obj updateLinks:(NSMutableSet<NSString*>*)urls {
|
||||||
int latestID = [[self.items valueForKeyPath:@"@max.sortIndex"] intValue];
|
int latestID = [[self.articles valueForKeyPath:@"@max.sortIndex"] intValue];
|
||||||
__block int newOnes = 0;
|
__block int newOnes = 0;
|
||||||
[obj.articles enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(RSParsedArticle * _Nonnull article, BOOL * _Nonnull stop) {
|
[obj.articles enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(RSParsedArticle * _Nonnull article, BOOL * _Nonnull stop) {
|
||||||
// reverse enumeration ensures correct article order
|
// reverse enumeration ensures correct article order
|
||||||
@@ -68,17 +93,17 @@
|
|||||||
Create article based on input and insert into core data storage.
|
Create article based on input and insert into core data storage.
|
||||||
*/
|
*/
|
||||||
- (void)insertArticle:(RSParsedArticle*)entry atIndex:(int)idx {
|
- (void)insertArticle:(RSParsedArticle*)entry atIndex:(int)idx {
|
||||||
FeedItem *b = [[FeedItem alloc] initWithEntity:FeedItem.entity insertIntoManagedObjectContext:self.managedObjectContext];
|
FeedArticle *fa = [[FeedArticle alloc] initWithEntity:FeedArticle.entity insertIntoManagedObjectContext:self.managedObjectContext];
|
||||||
b.sortIndex = (int32_t)idx;
|
fa.sortIndex = (int32_t)idx;
|
||||||
b.unread = YES;
|
fa.unread = YES;
|
||||||
b.guid = entry.guid;
|
fa.guid = entry.guid;
|
||||||
b.title = entry.title;
|
fa.title = entry.title;
|
||||||
b.abstract = entry.abstract;
|
fa.abstract = entry.abstract;
|
||||||
b.body = entry.body;
|
fa.body = entry.body;
|
||||||
b.author = entry.author;
|
fa.author = entry.author;
|
||||||
b.link = entry.link;
|
fa.link = entry.link;
|
||||||
b.published = entry.datePublished;
|
fa.published = entry.datePublished;
|
||||||
[self addItemsObject:b];
|
[self addArticlesObject:fa];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,28 +112,29 @@
|
|||||||
- (void)deleteArticlesWithLink:(NSMutableSet<NSString*>*)urls {
|
- (void)deleteArticlesWithLink:(NSMutableSet<NSString*>*)urls {
|
||||||
if (!urls || urls.count == 0)
|
if (!urls || urls.count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
self.articleCount -= (int32_t)urls.count;
|
self.articleCount -= (int32_t)urls.count;
|
||||||
for (FeedItem *item in self.items) {
|
for (FeedArticle *fa in self.articles) {
|
||||||
if ([urls containsObject:item.link]) {
|
if ([urls containsObject:fa.link]) {
|
||||||
[urls removeObject:item.link];
|
[urls removeObject:fa.link];
|
||||||
if (item.unread)
|
if (fa.unread)
|
||||||
self.unreadCount -= 1;
|
self.unreadCount -= 1;
|
||||||
// TODO: keep unread articles?
|
// TODO: keep unread articles?
|
||||||
[item.managedObjectContext deleteObject:item];
|
[fa.managedObjectContext deleteObject:fa];
|
||||||
if (urls.count == 0)
|
if (urls.count == 0)
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#pragma mark - Article Properties -
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@return Articles sorted by attribute @c sortIndex with descending order (newest items first).
|
@return Articles sorted by attribute @c sortIndex with descending order (newest items first).
|
||||||
*/
|
*/
|
||||||
- (NSArray<FeedItem*>*)sortedArticles {
|
- (NSArray<FeedArticle*>*)sortedArticles {
|
||||||
if (self.items.count == 0)
|
if (self.articles.count == 0)
|
||||||
return nil;
|
return nil;
|
||||||
return [self.items sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:NO]]];
|
return [self.articles sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:NO]]];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,9 +161,9 @@
|
|||||||
@param readFlag @c YES: mark items read; @c NO: mark items unread
|
@param readFlag @c YES: mark items read; @c NO: mark items unread
|
||||||
*/
|
*/
|
||||||
- (int)markAllArticlesRead:(BOOL)readFlag {
|
- (int)markAllArticlesRead:(BOOL)readFlag {
|
||||||
for (FeedItem *i in self.items) {
|
for (FeedArticle *fa in self.articles) {
|
||||||
if (i.unread == readFlag)
|
if (fa.unread == readFlag)
|
||||||
i.unread = !readFlag;
|
fa.unread = !readFlag;
|
||||||
}
|
}
|
||||||
int32_t oldCount = self.unreadCount;
|
int32_t oldCount = self.unreadCount;
|
||||||
int32_t newCount = (readFlag ? 0 : self.articleCount);
|
int32_t newCount = (readFlag ? 0 : self.articleCount);
|
||||||
|
|||||||
@@ -20,28 +20,25 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
#import "FeedConfig+CoreDataClass.h"
|
#import "FeedGroup+CoreDataClass.h"
|
||||||
|
|
||||||
@class FeedItem, RSParsedFeed;
|
@interface FeedGroup (Ext)
|
||||||
|
/// Enum type to distinguish different @c FeedGroup types: @c GROUP, @c FEED, @c SEPARATOR
|
||||||
@interface FeedConfig (Ext)
|
|
||||||
/// Enum type to distinguish different @c FeedConfig types
|
|
||||||
typedef enum int16_t {
|
typedef enum int16_t {
|
||||||
|
/// Other types: @c GROUP, @c FEED, @c SEPARATOR
|
||||||
GROUP = 0,
|
GROUP = 0,
|
||||||
FEED = 1,
|
FEED = 1,
|
||||||
SEPARATOR = 2
|
SEPARATOR = 2
|
||||||
} FeedConfigType;
|
} FeedGroupType;
|
||||||
|
|
||||||
@property (getter=typ, setter=setTyp:) FeedConfigType typ;
|
@property (readonly) FeedGroupType typ;
|
||||||
|
|
||||||
|
+ (instancetype)newGroup:(FeedGroupType)type inContext:(NSManagedObjectContext*)context;
|
||||||
|
- (void)setName:(NSString*)name andRefreshString:(NSString*)refreshStr;
|
||||||
// Handle children and parents
|
// Handle children and parents
|
||||||
- (NSString*)indexPathString;
|
- (NSString*)indexPathString;
|
||||||
- (NSMutableArray<FeedConfig*>*)allParents;
|
- (NSMutableArray<FeedGroup*>*)allParents;
|
||||||
- (BOOL)iterateSorted:(BOOL)ordered overDescendantFeeds:(void(^)(Feed *feed, BOOL* cancel))block;
|
- (BOOL)iterateSorted:(BOOL)ordered overDescendantFeeds:(void(^)(Feed *feed, BOOL* cancel))block;
|
||||||
// Update feed and meta
|
|
||||||
- (void)updateRSSFeed:(RSParsedFeed*)obj;
|
|
||||||
- (void)setEtag:(NSString*)etag modified:(NSString*)modified;
|
|
||||||
- (void)calculateAndSetScheduled;
|
|
||||||
// Printing
|
// Printing
|
||||||
- (NSString*)readableRefreshString;
|
|
||||||
- (NSString*)readableDescription;
|
- (NSString*)readableDescription;
|
||||||
@end
|
@end
|
||||||
@@ -20,16 +20,31 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
#import "FeedConfig+Ext.h"
|
#import "FeedGroup+Ext.h"
|
||||||
|
#import "FeedMeta+Ext.h"
|
||||||
#import "Feed+Ext.h"
|
#import "Feed+Ext.h"
|
||||||
#import "FeedMeta+CoreDataClass.h"
|
|
||||||
#import "Constants.h"
|
|
||||||
|
|
||||||
@implementation FeedConfig (Ext)
|
@implementation FeedGroup (Ext)
|
||||||
/// Enum tpye getter see @c FeedConfigType
|
/// Enum tpye getter see @c FeedGroupType
|
||||||
- (FeedConfigType)typ { return (FeedConfigType)self.type; }
|
- (FeedGroupType)typ { return (FeedGroupType)self.type; }
|
||||||
/// Enum type setter see @c FeedConfigType
|
/// Enum type setter see @c FeedGroupType
|
||||||
- (void)setTyp:(FeedConfigType)typ { self.type = typ; }
|
- (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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - Handle Children And Parents -
|
#pragma mark - Handle Children And Parents -
|
||||||
@@ -43,14 +58,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// @return Children sorted by attribute @c sortIndex (same order as in preferences).
|
/// @return Children sorted by attribute @c sortIndex (same order as in preferences).
|
||||||
- (NSArray<FeedConfig*>*)sortedChildren {
|
- (NSArray<FeedGroup*>*)sortedChildren {
|
||||||
if (self.children.count == 0)
|
if (self.children.count == 0)
|
||||||
return nil;
|
return nil;
|
||||||
return [self.children sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]]];
|
return [self.children sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]]];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @return @c NSArray of all ancestors: First object is root. Last object is the @c FeedConfig that executed the command.
|
/// @return @c NSArray of all ancestors: First object is root. Last object is the @c FeedGroup that executed the command.
|
||||||
- (NSMutableArray<FeedConfig*>*)allParents {
|
- (NSMutableArray<FeedGroup*>*)allParents {
|
||||||
if (self.parent == nil)
|
if (self.parent == nil)
|
||||||
return [NSMutableArray arrayWithObject:self];
|
return [NSMutableArray arrayWithObject:self];
|
||||||
NSMutableArray *arr = [self.parent allParents];
|
NSMutableArray *arr = [self.parent allParents];
|
||||||
@@ -71,8 +86,8 @@
|
|||||||
block(self.feed, &stopEarly);
|
block(self.feed, &stopEarly);
|
||||||
if (stopEarly) return NO;
|
if (stopEarly) return NO;
|
||||||
} else {
|
} else {
|
||||||
for (FeedConfig *fc in (ordered ? [self sortedChildren] : self.children)) {
|
for (FeedGroup *fg in (ordered ? [self sortedChildren] : self.children)) {
|
||||||
if (![fc iterateSorted:ordered overDescendantFeeds:block])
|
if (![fg iterateSorted:ordered overDescendantFeeds:block])
|
||||||
return NO;
|
return NO;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,57 +95,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - Update Feed And Meta -
|
|
||||||
|
|
||||||
|
|
||||||
/// Delete any existing feed object and parse new one. Read state will be copied.
|
|
||||||
- (void)updateRSSFeed:(RSParsedFeed*)obj {
|
|
||||||
if (!self.feed) {
|
|
||||||
self.feed = [[Feed alloc] initWithEntity:Feed.entity insertIntoManagedObjectContext:self.managedObjectContext];
|
|
||||||
self.feed.indexPath = [self indexPathString];
|
|
||||||
}
|
|
||||||
int32_t unreadBefore = self.feed.unreadCount;
|
|
||||||
[self.feed updateWithRSS:obj];
|
|
||||||
NSNumber *cDiff = [NSNumber numberWithInteger:self.feed.unreadCount - unreadBefore];
|
|
||||||
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged object:cDiff];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update FeedMeta or create new one if needed.
|
|
||||||
- (void)setEtag:(NSString*)etag modified:(NSString*)modified {
|
|
||||||
if (!self.meta) {
|
|
||||||
self.meta = [[FeedMeta alloc] initWithEntity:FeedMeta.entity insertIntoManagedObjectContext:self.managedObjectContext];
|
|
||||||
}
|
|
||||||
if (![self.meta.httpEtag isEqualToString:etag]) self.meta.httpEtag = etag;
|
|
||||||
if (![self.meta.httpModified isEqualToString:modified]) self.meta.httpModified = modified;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculate date from @c refreshNum and @c refreshUnit and set as next scheduled feed update.
|
|
||||||
- (void)calculateAndSetScheduled {
|
|
||||||
self.scheduled = [[NSDate date] dateByAddingTimeInterval:[self timeInterval]];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// @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];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - Printing -
|
#pragma mark - Printing -
|
||||||
|
|
||||||
|
|
||||||
/// @return Formatted string for update interval ( e.g., @c 30m or @c 12h )
|
|
||||||
- (NSString*)readableRefreshString {
|
|
||||||
return [NSString stringWithFormat:@"%d%c", self.refreshNum, [@"smhdw" characterAtIndex:self.refreshUnit % 5]];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// @return Simplified description of the feed object.
|
/// @return Simplified description of the feed object.
|
||||||
- (NSString*)readableDescription {
|
- (NSString*)readableDescription {
|
||||||
switch (self.typ) {
|
switch (self.typ) {
|
||||||
case SEPARATOR: return @"-------------";
|
case SEPARATOR: return @"-------------";
|
||||||
case GROUP: return [NSString stringWithFormat:@"%@", self.name];
|
case GROUP: return [NSString stringWithFormat:@"%@", self.name];
|
||||||
case FEED:
|
case FEED:
|
||||||
return [NSString stringWithFormat:@"%@ (%@) - %@", self.name, self.url, [self readableRefreshString]];
|
return [NSString stringWithFormat:@"%@ (%@) - %@", self.name, self.feed.meta.url, self.refreshStr];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
33
baRSS/Categories/FeedMeta+Ext.h
Normal file
33
baRSS/Categories/FeedMeta+Ext.h
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
//
|
||||||
|
// 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 "FeedMeta+CoreDataClass.h"
|
||||||
|
|
||||||
|
@interface FeedMeta (Ext)
|
||||||
|
- (void)setErrorAndPostponeSchedule;
|
||||||
|
- (void)calculateAndSetScheduled;
|
||||||
|
|
||||||
|
- (void)setEtag:(NSString*)etag modified:(NSString*)modified;
|
||||||
|
- (BOOL)setURL:(NSString*)url refresh:(int32_t)refresh unit:(int16_t)unit;
|
||||||
|
|
||||||
|
- (NSString*)readableRefreshString;
|
||||||
|
@end
|
||||||
71
baRSS/Categories/FeedMeta+Ext.m
Normal file
71
baRSS/Categories/FeedMeta+Ext.m
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
//
|
||||||
|
// 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 "FeedMeta+Ext.h"
|
||||||
|
|
||||||
|
@implementation FeedMeta (Ext)
|
||||||
|
|
||||||
|
/// Increment @c errorCount (max. 19) and set new @c scheduled (2^N seconds, max. 6 days).
|
||||||
|
- (void)setErrorAndPostponeSchedule {
|
||||||
|
int16_t n = self.errorCount + 1;
|
||||||
|
self.errorCount = (n < 1 ? 1 : (n > 19 ? 19 : n)); // between: 2 sec and 6 days
|
||||||
|
NSTimeInterval retryWaitTime = pow(2, self.errorCount); // 2^n seconds
|
||||||
|
self.scheduled = [NSDate dateWithTimeIntervalSinceNow:retryWaitTime];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
self.scheduled = (interval <= 0 ? nil : [[NSDate date] dateByAddingTimeInterval:interval]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set etag and modified attributes. @note 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Set download url and refresh interval (popup button selection). @note Only values that differ will be updated.
|
||||||
|
|
||||||
|
@return @c YES if refresh interval has changed
|
||||||
|
*/
|
||||||
|
- (BOOL)setURL:(NSString*)url refresh:(int32_t)refresh unit:(int16_t)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;
|
||||||
|
return intervalChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @return Formatted string for update interval ( e.g., @c 30m or @c 12h )
|
||||||
|
- (NSString*)readableRefreshString {
|
||||||
|
return [NSString stringWithFormat:@"%d%c", self.refreshNum, [@"smhdw" characterAtIndex:self.refreshUnit % 5]];
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -7,24 +7,12 @@
|
|||||||
<attribute name="subtitle" optional="YES" attributeType="String" syncable="YES"/>
|
<attribute name="subtitle" optional="YES" attributeType="String" syncable="YES"/>
|
||||||
<attribute name="title" optional="YES" attributeType="String" syncable="YES"/>
|
<attribute name="title" optional="YES" attributeType="String" syncable="YES"/>
|
||||||
<attribute name="unreadCount" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
|
<attribute name="unreadCount" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
|
||||||
<relationship name="config" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="FeedConfig" inverseName="feed" inverseEntity="FeedConfig" syncable="YES"/>
|
<relationship name="articles" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="FeedArticle" inverseName="feed" inverseEntity="FeedArticle" syncable="YES"/>
|
||||||
<relationship name="items" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="FeedItem" inverseName="feed" inverseEntity="FeedItem" syncable="YES"/>
|
<relationship name="group" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="FeedGroup" inverseName="feed" inverseEntity="FeedGroup" syncable="YES"/>
|
||||||
|
<relationship name="icon" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="FeedIcon" inverseName="feed" inverseEntity="FeedIcon" syncable="YES"/>
|
||||||
|
<relationship name="meta" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="FeedMeta" inverseName="feed" inverseEntity="FeedMeta" syncable="YES"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="FeedConfig" representedClassName="FeedConfig" syncable="YES" codeGenerationType="class">
|
<entity name="FeedArticle" representedClassName="FeedArticle" syncable="YES" codeGenerationType="class">
|
||||||
<attribute name="errorCount" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
|
|
||||||
<attribute name="name" optional="YES" attributeType="String" syncable="YES"/>
|
|
||||||
<attribute name="refreshNum" optional="YES" attributeType="Integer 32" defaultValueString="-1" usesScalarValueType="YES" syncable="YES"/>
|
|
||||||
<attribute name="refreshUnit" optional="YES" attributeType="Integer 16" defaultValueString="-1" usesScalarValueType="YES" customClassName="NSUInteger" syncable="YES"/>
|
|
||||||
<attribute name="scheduled" optional="YES" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
|
|
||||||
<attribute name="sortIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
|
|
||||||
<attribute name="type" optional="YES" attributeType="Integer 16" defaultValueString="-1" usesScalarValueType="YES" syncable="YES"/>
|
|
||||||
<attribute name="url" optional="YES" attributeType="String" syncable="YES"/>
|
|
||||||
<relationship name="children" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="FeedConfig" inverseName="parent" inverseEntity="FeedConfig" syncable="YES"/>
|
|
||||||
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Feed" inverseName="config" inverseEntity="Feed" syncable="YES"/>
|
|
||||||
<relationship name="meta" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="FeedMeta" inverseName="config" inverseEntity="FeedMeta" syncable="YES"/>
|
|
||||||
<relationship name="parent" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="FeedConfig" inverseName="children" inverseEntity="FeedConfig" syncable="YES"/>
|
|
||||||
</entity>
|
|
||||||
<entity name="FeedItem" representedClassName="FeedItem" syncable="YES" codeGenerationType="class">
|
|
||||||
<attribute name="abstract" optional="YES" attributeType="String" syncable="YES"/>
|
<attribute name="abstract" optional="YES" attributeType="String" syncable="YES"/>
|
||||||
<attribute name="author" optional="YES" attributeType="String" syncable="YES"/>
|
<attribute name="author" optional="YES" attributeType="String" syncable="YES"/>
|
||||||
<attribute name="body" optional="YES" attributeType="String" syncable="YES"/>
|
<attribute name="body" optional="YES" attributeType="String" syncable="YES"/>
|
||||||
@@ -34,18 +22,36 @@
|
|||||||
<attribute name="sortIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
|
<attribute name="sortIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
|
||||||
<attribute name="title" optional="YES" attributeType="String" syncable="YES"/>
|
<attribute name="title" optional="YES" attributeType="String" syncable="YES"/>
|
||||||
<attribute name="unread" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
|
<attribute name="unread" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
|
||||||
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="items" inverseEntity="Feed" syncable="YES"/>
|
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="articles" inverseEntity="Feed" syncable="YES"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="FeedGroup" representedClassName="FeedGroup" syncable="YES" codeGenerationType="class">
|
||||||
|
<attribute name="name" optional="YES" attributeType="String" syncable="YES"/>
|
||||||
|
<attribute name="refreshStr" optional="YES" attributeType="String" syncable="YES"/>
|
||||||
|
<attribute name="sortIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
|
||||||
|
<attribute name="type" optional="YES" attributeType="Integer 16" defaultValueString="-1" usesScalarValueType="YES" syncable="YES"/>
|
||||||
|
<relationship name="children" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="FeedGroup" inverseName="parent" inverseEntity="FeedGroup" syncable="YES"/>
|
||||||
|
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Feed" inverseName="group" inverseEntity="Feed" syncable="YES"/>
|
||||||
|
<relationship name="parent" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="FeedGroup" inverseName="children" inverseEntity="FeedGroup" syncable="YES"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="FeedIcon" representedClassName="FeedIcon" syncable="YES" codeGenerationType="class">
|
||||||
|
<attribute name="icon" optional="YES" attributeType="Binary" customClassName="NSImage" syncable="YES"/>
|
||||||
|
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="icon" inverseEntity="Feed" syncable="YES"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="FeedMeta" representedClassName="FeedMeta" syncable="YES" codeGenerationType="class">
|
<entity name="FeedMeta" representedClassName="FeedMeta" syncable="YES" codeGenerationType="class">
|
||||||
<attribute name="httpEtag" optional="YES" attributeType="String" syncable="YES"/>
|
<attribute name="errorCount" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
|
||||||
<attribute name="httpModified" optional="YES" attributeType="String" syncable="YES"/>
|
<attribute name="etag" optional="YES" attributeType="String" syncable="YES"/>
|
||||||
<attribute name="icon" optional="YES" attributeType="Binary" customClassName="NSImage" syncable="YES"/>
|
<attribute name="modified" optional="YES" attributeType="String" syncable="YES"/>
|
||||||
<relationship name="config" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="FeedConfig" inverseName="meta" inverseEntity="FeedConfig" syncable="YES"/>
|
<attribute name="refreshNum" optional="YES" attributeType="Integer 32" defaultValueString="-1" usesScalarValueType="YES" syncable="YES"/>
|
||||||
|
<attribute name="refreshUnit" optional="YES" attributeType="Integer 16" defaultValueString="-1" usesScalarValueType="YES" customClassName="NSUInteger" syncable="YES"/>
|
||||||
|
<attribute name="scheduled" optional="YES" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
|
||||||
|
<attribute name="url" optional="YES" attributeType="String" syncable="YES"/>
|
||||||
|
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="meta" inverseEntity="Feed" syncable="YES"/>
|
||||||
</entity>
|
</entity>
|
||||||
<elements>
|
<elements>
|
||||||
<element name="Feed" positionX="-229.09375" positionY="-2.30859375" width="128" height="165"/>
|
<element name="Feed" positionX="-278.84765625" positionY="-112.953125" width="128" height="195"/>
|
||||||
<element name="FeedConfig" positionX="-441.87109375" positionY="-47.390625" width="128" height="225"/>
|
<element name="FeedGroup" positionX="-460.37890625" positionY="-111.62890625" width="130.52734375" height="150"/>
|
||||||
<element name="FeedItem" positionX="-28.140625" positionY="-17.359375" width="128" height="195"/>
|
<element name="FeedArticle" positionX="-96.77734375" positionY="-113.83984375" width="128" height="195"/>
|
||||||
<element name="FeedMeta" positionX="-234" positionY="72" width="128" height="105"/>
|
<element name="FeedIcon" positionX="-202.79296875" positionY="137.71875" width="128" height="75"/>
|
||||||
|
<element name="FeedMeta" positionX="-348.02734375" positionY="136.89453125" width="128" height="165"/>
|
||||||
</elements>
|
</elements>
|
||||||
</model>
|
</model>
|
||||||
@@ -28,5 +28,5 @@
|
|||||||
+ (void)registerNetworkChangeNotification;
|
+ (void)registerNetworkChangeNotification;
|
||||||
+ (void)unregisterNetworkChangeNotification;
|
+ (void)unregisterNetworkChangeNotification;
|
||||||
+ (BOOL)isNetworkReachable;
|
+ (BOOL)isNetworkReachable;
|
||||||
+ (void)scheduleNextUpdate:(BOOL)forceUpdate;
|
+ (void)scheduleNextUpdateForced:(BOOL)flag;
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -23,6 +23,9 @@
|
|||||||
#import "FeedDownload.h"
|
#import "FeedDownload.h"
|
||||||
#import "Constants.h"
|
#import "Constants.h"
|
||||||
#import "StoreCoordinator.h"
|
#import "StoreCoordinator.h"
|
||||||
|
#import "Feed+Ext.h"
|
||||||
|
#import "FeedMeta+Ext.h"
|
||||||
|
|
||||||
#import <SystemConfiguration/SystemConfiguration.h>
|
#import <SystemConfiguration/SystemConfiguration.h>
|
||||||
|
|
||||||
static SCNetworkReachabilityRef _reachability = NULL;
|
static SCNetworkReachabilityRef _reachability = NULL;
|
||||||
@@ -31,6 +34,7 @@ static BOOL _isReachable = NO;
|
|||||||
|
|
||||||
@implementation FeedDownload
|
@implementation FeedDownload
|
||||||
|
|
||||||
|
/// @return New request with no caching policy and timeout interval of 30 seconds.
|
||||||
+ (NSMutableURLRequest*)newRequestURL:(NSString*)url {
|
+ (NSMutableURLRequest*)newRequestURL:(NSString*)url {
|
||||||
NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]];
|
NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]];
|
||||||
req.timeoutInterval = 30;
|
req.timeoutInterval = 30;
|
||||||
@@ -40,16 +44,20 @@ static BOOL _isReachable = NO;
|
|||||||
return req;
|
return req;
|
||||||
}
|
}
|
||||||
|
|
||||||
+ (NSURLRequest*)newRequest:(FeedConfig*)config {
|
/// @return New request with etag and modified headers set.
|
||||||
NSMutableURLRequest *req = [self newRequestURL:config.url];
|
+ (NSURLRequest*)newRequest:(FeedMeta*)meta {
|
||||||
NSString* etag = [config.meta.httpEtag stringByReplacingOccurrencesOfString:@"-gzip" withString:@""];
|
NSMutableURLRequest *req = [self newRequestURL:meta.url];
|
||||||
if (config.meta.httpModified.length > 0)
|
NSString* etag = [meta.etag stringByReplacingOccurrencesOfString:@"-gzip" withString:@""];
|
||||||
[req setValue:config.meta.httpModified forHTTPHeaderField:@"If-Modified-Since"];
|
if (meta.modified.length > 0)
|
||||||
|
[req setValue:meta.modified forHTTPHeaderField:@"If-Modified-Since"];
|
||||||
if (etag.length > 0)
|
if (etag.length > 0)
|
||||||
[req setValue:etag forHTTPHeaderField:@"If-None-Match"]; // ETag
|
[req setValue:etag forHTTPHeaderField:@"If-None-Match"]; // ETag
|
||||||
return req;
|
return req;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Perform feed download request from URL alone. Not updating any @c Feed item.
|
||||||
|
*/
|
||||||
+ (void)newFeed:(NSString *)url block:(void(^)(RSParsedFeed *feed, NSError* error, NSHTTPURLResponse* response))block {
|
+ (void)newFeed:(NSString *)url block:(void(^)(RSParsedFeed *feed, NSError* error, NSHTTPURLResponse* response))block {
|
||||||
[[[NSURLSession sharedSession] dataTaskWithRequest:[self newRequestURL:url] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
|
[[[NSURLSession sharedSession] dataTaskWithRequest:[self newRequestURL:url] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
|
||||||
NSHTTPURLResponse* httpResponse = (NSHTTPURLResponse*)response;
|
NSHTTPURLResponse* httpResponse = (NSHTTPURLResponse*)response;
|
||||||
@@ -59,6 +67,10 @@ static BOOL _isReachable = NO;
|
|||||||
}
|
}
|
||||||
RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:url];
|
RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:url];
|
||||||
RSParseFeed(xml, ^(RSParsedFeed * _Nullable parsedFeed, NSError * _Nullable err) {
|
RSParseFeed(xml, ^(RSParsedFeed * _Nullable parsedFeed, NSError * _Nullable err) {
|
||||||
|
if (!err && (!parsedFeed || parsedFeed.articles.count == 0)) { // TODO: this should be fixed in RSXMLParser
|
||||||
|
NSString *errDesc = NSLocalizedString(@"URL does not contain a RSS feed. Can't parse feed items.", nil);
|
||||||
|
err = [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorUnsupportedURL userInfo:@{NSLocalizedDescriptionKey: errDesc}];
|
||||||
|
}
|
||||||
block(parsedFeed, err, httpResponse);
|
block(parsedFeed, err, httpResponse);
|
||||||
});
|
});
|
||||||
}] resume];
|
}] resume];
|
||||||
@@ -68,7 +80,12 @@ static BOOL _isReachable = NO;
|
|||||||
#pragma mark - Update existing feeds -
|
#pragma mark - Update existing feeds -
|
||||||
|
|
||||||
|
|
||||||
+ (void)scheduleNextUpdate:(BOOL)forceUpdate {
|
/**
|
||||||
|
Get date of next update schedule and start @c updateTimer.
|
||||||
|
|
||||||
|
@param forceUpdate If @c YES all feeds will be downloaded regardless of scheduled date.
|
||||||
|
*/
|
||||||
|
+ (void)scheduleNextUpdateForced:(BOOL)forceUpdate {
|
||||||
static NSTimer *_updateTimer;
|
static NSTimer *_updateTimer;
|
||||||
@synchronized (_updateTimer) { // TODO: dig into analyzer warning
|
@synchronized (_updateTimer) { // TODO: dig into analyzer warning
|
||||||
if (_updateTimer) {
|
if (_updateTimer) {
|
||||||
@@ -80,7 +97,8 @@ static BOOL _isReachable = NO;
|
|||||||
NSDate *nextTime = [NSDate dateWithTimeIntervalSinceNow:0.2];
|
NSDate *nextTime = [NSDate dateWithTimeIntervalSinceNow:0.2];
|
||||||
if (!forceUpdate) {
|
if (!forceUpdate) {
|
||||||
nextTime = [StoreCoordinator nextScheduledUpdate];
|
nextTime = [StoreCoordinator nextScheduledUpdate];
|
||||||
if (!nextTime || [nextTime timeIntervalSinceNow] < 0) { // mostly, if app was closed for a long time
|
if (!nextTime) return; // no timer means no feeds to update
|
||||||
|
if ([nextTime timeIntervalSinceNow] < 0) { // mostly, if app was closed for a long time
|
||||||
nextTime = [NSDate dateWithTimeIntervalSinceNow:2]; // TODO: retry in 2 sec?
|
nextTime = [NSDate dateWithTimeIntervalSinceNow:2]; // TODO: retry in 2 sec?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,82 +109,97 @@ static BOOL _isReachable = NO;
|
|||||||
[[NSRunLoop mainRunLoop] addTimer:_updateTimer forMode:NSRunLoopCommonModes];
|
[[NSRunLoop mainRunLoop] addTimer:_updateTimer forMode:NSRunLoopCommonModes];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Called when schedule timer has run out (earliest scheduled date). Or if forced by user request.
|
||||||
|
|
||||||
|
@param timer @c NSTimer @c .userInfo should contain a @c BOOL value whether to force an update of all feeds @c (YES).
|
||||||
|
*/
|
||||||
+ (void)scheduledUpdateTimer:(NSTimer*)timer {
|
+ (void)scheduledUpdateTimer:(NSTimer*)timer {
|
||||||
NSLog(@"fired");
|
NSLog(@"fired");
|
||||||
BOOL forceAll = [timer.userInfo boolValue];
|
BOOL forceAll = [timer.userInfo boolValue];
|
||||||
// TODO: check internet connection
|
// TODO: check internet connection
|
||||||
// TODO: disable menu item 'update all' during update
|
// TODO: disable menu item 'update all' during update
|
||||||
__block NSManagedObjectContext *childContext = [StoreCoordinator createChildContext];
|
__block NSManagedObjectContext *childContext = [StoreCoordinator createChildContext];
|
||||||
NSArray<FeedConfig*> *list = [StoreCoordinator getListOfFeedsThatNeedUpdate:forceAll inContext:childContext];
|
NSArray<Feed*> *list = [StoreCoordinator getListOfFeedsThatNeedUpdate:forceAll inContext:childContext];
|
||||||
if (list.count == 0) {
|
if (list.count == 0) {
|
||||||
NSLog(@"ERROR: Something went wrong, timer fired too early.");
|
NSLog(@"ERROR: Something went wrong, timer fired too early.");
|
||||||
[childContext reset];
|
[childContext reset];
|
||||||
childContext = nil;
|
childContext = nil;
|
||||||
// thechnically should never happen, anyway we need to reset the timer
|
// thechnically should never happen, anyway we need to reset the timer
|
||||||
[self scheduleNextUpdate:NO]; // NO, since forceAll will get ALL items and shouldn't be 0
|
[self scheduleNextUpdateForced:NO]; // NO, since forceAll will get ALL items and shouldn't be 0
|
||||||
return; // nothing to do here
|
return; // nothing to do here
|
||||||
}
|
}
|
||||||
dispatch_group_t group = dispatch_group_create();
|
dispatch_group_t group = dispatch_group_create();
|
||||||
for (FeedConfig *c in list) {
|
for (Feed *feed in list) {
|
||||||
[self downloadFeedForConfig:c group:group];
|
[self downloadFeed:feed group:group];
|
||||||
}
|
}
|
||||||
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
|
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
|
||||||
[StoreCoordinator saveContext:childContext andParent:YES];
|
[StoreCoordinator saveContext:childContext andParent:YES];
|
||||||
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:[list valueForKeyPath:@"objectID"]];
|
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:[list valueForKeyPath:@"objectID"]];
|
||||||
[childContext reset];
|
[childContext reset];
|
||||||
childContext = nil;
|
childContext = nil;
|
||||||
[self scheduleNextUpdate:NO]; // after forced update, continue regular cycle
|
[self scheduleNextUpdateForced:NO]; // after forced update, continue regular cycle
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
+ (void)downloadFeedForConfig:(FeedConfig*)config group:(dispatch_group_t)group {
|
/**
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
+ (void)downloadFeed:(Feed*)feed group:(dispatch_group_t)group {
|
||||||
if (!_isReachable) return;
|
if (!_isReachable) return;
|
||||||
dispatch_group_enter(group);
|
dispatch_group_enter(group);
|
||||||
[[[NSURLSession sharedSession] dataTaskWithRequest:[self newRequest:config] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
|
[[[NSURLSession sharedSession] dataTaskWithRequest:[self newRequest:feed.meta] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
|
||||||
[config.managedObjectContext performBlock:^{
|
[feed.managedObjectContext performBlock:^{
|
||||||
// core data block inside of url session block; otherwise config access will EXC_BAD_INSTRUCTION
|
// core data block inside of url session block; otherwise access will EXC_BAD_INSTRUCTION
|
||||||
if (error) {
|
if (error) {
|
||||||
int16_t n = config.errorCount + 1;
|
[feed.meta setErrorAndPostponeSchedule];
|
||||||
config.errorCount = (n < 1 ? 1 : (n > 19 ? 19 : n)); // between: 2 sec and 6 days
|
|
||||||
NSTimeInterval retryWaitTime = pow(2, config.errorCount); // 2^n seconds
|
|
||||||
config.scheduled = [NSDate dateWithTimeIntervalSinceNow:retryWaitTime];
|
|
||||||
// TODO: remove logging
|
// TODO: remove logging
|
||||||
NSLog(@"Error loading: %@ (%d)", response.URL, config.errorCount);
|
NSLog(@"Error loading: %@ (%d)", response.URL, feed.meta.errorCount);
|
||||||
} else {
|
} else {
|
||||||
config.errorCount = 0; // reset counter
|
feed.meta.errorCount = 0; // reset counter
|
||||||
[self downloadSuccessful:data forFeed:config response:(NSHTTPURLResponse*)response];
|
[self downloadSuccessful:data forFeed:feed response:(NSHTTPURLResponse*)response];
|
||||||
}
|
}
|
||||||
dispatch_group_leave(group);
|
dispatch_group_leave(group);
|
||||||
}];
|
}];
|
||||||
}] resume];
|
}] resume];
|
||||||
}
|
}
|
||||||
|
|
||||||
+ (void)downloadSuccessful:(NSData*)data forFeed:(FeedConfig*)config response:(NSHTTPURLResponse*)http {
|
/**
|
||||||
|
Parse RSS feed data and save to persistent store. If HTTP 304 (not modified) skip feed evaluation.
|
||||||
|
|
||||||
|
@param data Raw data from request.
|
||||||
|
@param feed @c Feed on which the update is executed.
|
||||||
|
@param http Download response containing the statusCode and etag / modified headers.
|
||||||
|
*/
|
||||||
|
+ (void)downloadSuccessful:(NSData*)data forFeed:(Feed*)feed response:(NSHTTPURLResponse*)http {
|
||||||
if ([http statusCode] != 304) {
|
if ([http statusCode] != 304) {
|
||||||
// should be fine to call synchronous since dataTask is already in the background (always? proof?)
|
// should be fine to call synchronous since dataTask is already in the background (always? proof?)
|
||||||
RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:config.url];
|
RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:feed.meta.url];
|
||||||
RSParsedFeed *parsed = RSParseFeedSync(xml, NULL);
|
RSParsedFeed *parsed = RSParseFeedSync(xml, NULL);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
// TODO: add support for media player?
|
// TODO: add support for media player?
|
||||||
// <enclosure url="https://url.mp3" length="63274022" type="audio/mpeg" />
|
// <enclosure url="https://url.mp3" length="63274022" type="audio/mpeg" />
|
||||||
[config updateRSSFeed:parsed];
|
[feed updateWithRSS:parsed postUnreadCountChange:YES];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
[config setEtag:[http allHeaderFields][@"Etag"] modified:[http allHeaderFields][@"Date"]]; // @"Expires", @"Last-Modified"
|
[feed.meta setEtag:[http allHeaderFields][@"Etag"] modified:[http allHeaderFields][@"Date"]]; // @"Expires", @"Last-Modified"
|
||||||
// Don't update redirected url since it happened in the background; User may not recognize url
|
// Don't update redirected url since it happened in the background; User may not recognize url
|
||||||
[config calculateAndSetScheduled];
|
[feed.meta calculateAndSetScheduled];
|
||||||
// [config mergeChangesAndSave];
|
// TODO: save changes for this feed only?
|
||||||
// [config.managedObjectContext performBlock:^{
|
// [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:feed.objectID];
|
||||||
// [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:config.objectID];
|
|
||||||
// }];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - Network Connection -
|
#pragma mark - Network Connection -
|
||||||
|
|
||||||
|
|
||||||
|
/// External getter to check wheter current network state is reachable.
|
||||||
+ (BOOL)isNetworkReachable { return _isReachable; }
|
+ (BOOL)isNetworkReachable { return _isReachable; }
|
||||||
|
|
||||||
|
/// Set callback on @c self to listen for network reachability changes.
|
||||||
+ (void)registerNetworkChangeNotification {
|
+ (void)registerNetworkChangeNotification {
|
||||||
// https://stackoverflow.com/questions/11240196/notification-when-wifi-connected-os-x
|
// https://stackoverflow.com/questions/11240196/notification-when-wifi-connected-os-x
|
||||||
if (_reachability != NULL) return;
|
if (_reachability != NULL) return;
|
||||||
@@ -184,6 +217,7 @@ static BOOL _isReachable = NO;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove @c self callback (network reachability changes).
|
||||||
+ (void)unregisterNetworkChangeNotification {
|
+ (void)unregisterNetworkChangeNotification {
|
||||||
if (_reachability != NULL) {
|
if (_reachability != NULL) {
|
||||||
SCNetworkReachabilitySetCallback(_reachability, nil, nil);
|
SCNetworkReachabilitySetCallback(_reachability, nil, nil);
|
||||||
@@ -193,6 +227,7 @@ static BOOL _isReachable = NO;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Called when network interface or reachability changes.
|
||||||
static void networkReachabilityCallback(SCNetworkReachabilityRef target, SCNetworkConnectionFlags flags, void *object) {
|
static void networkReachabilityCallback(SCNetworkReachabilityRef target, SCNetworkConnectionFlags flags, void *object) {
|
||||||
if (_reachability == NULL)
|
if (_reachability == NULL)
|
||||||
return;
|
return;
|
||||||
@@ -205,9 +240,10 @@ static void networkReachabilityCallback(SCNetworkReachabilityRef target, SCNetwo
|
|||||||
NSLog(@"not reachable");
|
NSLog(@"not reachable");
|
||||||
}
|
}
|
||||||
// schedule regardless of state (if not reachable timer will be canceled)
|
// schedule regardless of state (if not reachable timer will be canceled)
|
||||||
[FeedDownload scheduleNextUpdate:NO];
|
[FeedDownload scheduleNextUpdateForced:NO];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return @c YES if network connection established.
|
||||||
+ (BOOL)hasConnectivity:(SCNetworkReachabilityFlags)flags {
|
+ (BOOL)hasConnectivity:(SCNetworkReachabilityFlags)flags {
|
||||||
if ((flags & kSCNetworkReachabilityFlagsReachable) == 0)
|
if ((flags & kSCNetworkReachabilityFlagsReachable) == 0)
|
||||||
return NO;
|
return NO;
|
||||||
|
|||||||
@@ -21,22 +21,20 @@
|
|||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
#import <Cocoa/Cocoa.h>
|
#import <Cocoa/Cocoa.h>
|
||||||
|
#import "ModalSheet.h"
|
||||||
|
|
||||||
@class FeedConfig;
|
@class FeedGroup;
|
||||||
|
|
||||||
@protocol ModalEditDelegate <NSObject>
|
@interface ModalEditDialog : NSViewController
|
||||||
- (void)modalDidUpdateFeedConfig:(FeedConfig*)config;
|
+ (instancetype)modalWith:(FeedGroup*)group;
|
||||||
@end
|
- (ModalSheet*)getModalSheet;
|
||||||
|
- (void)applyChangesToCoreDataObject;
|
||||||
@protocol ModalFeedConfigEdit <NSObject>
|
|
||||||
@property (weak) id<ModalEditDelegate> delegate;
|
|
||||||
- (void)updateRepresentedObject; // must call [item.managedObjectContext refreshObject:item mergeChanges:YES];
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
|
|
||||||
@interface ModalFeedEdit : NSViewController <ModalFeedConfigEdit, NSTextFieldDelegate>
|
@interface ModalFeedEdit : ModalEditDialog <NSTextFieldDelegate>
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@interface ModalGroupEdit : NSViewController <ModalFeedConfigEdit>
|
@interface ModalGroupEdit : ModalEditDialog
|
||||||
@end
|
@end
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,42 @@
|
|||||||
#import "ModalFeedEdit.h"
|
#import "ModalFeedEdit.h"
|
||||||
#import "FeedDownload.h"
|
#import "FeedDownload.h"
|
||||||
#import "StoreCoordinator.h"
|
#import "StoreCoordinator.h"
|
||||||
|
#import "Feed+Ext.h"
|
||||||
|
#import "FeedMeta+Ext.h"
|
||||||
|
#import "FeedGroup+Ext.h"
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - ModalEditDialog -
|
||||||
|
|
||||||
|
|
||||||
|
@interface ModalEditDialog()
|
||||||
|
@property (strong) FeedGroup *feedGroup;
|
||||||
|
@property (strong) ModalSheet *modalSheet;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation ModalEditDialog
|
||||||
|
/// Dedicated initializer for @c ModalEditDialog subclasses. Ensures @c .feedGroup property is set.
|
||||||
|
+ (instancetype)modalWith:(FeedGroup*)group {
|
||||||
|
ModalEditDialog *diag = [self new];
|
||||||
|
diag.feedGroup = group;
|
||||||
|
return diag;
|
||||||
|
}
|
||||||
|
/// @return New @c ModalSheet with its subclass @c .view property as dialog content.
|
||||||
|
- (ModalSheet *)getModalSheet {
|
||||||
|
if (!self.modalSheet)
|
||||||
|
self.modalSheet = [ModalSheet modalWithView:self.view];
|
||||||
|
return self.modalSheet;
|
||||||
|
}
|
||||||
|
/// This method should be overridden by subclasses. Used to save changes to persistent store.
|
||||||
|
- (void)applyChangesToCoreDataObject {
|
||||||
|
NSLog(@"[%@] is missing method: -(void)applyChangesToCoreDataObject", [self class]);
|
||||||
|
NSAssert(NO, @"Override required!");
|
||||||
|
}
|
||||||
|
@end
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - ModalFeedEdit -
|
||||||
|
|
||||||
|
|
||||||
@interface ModalFeedEdit()
|
@interface ModalFeedEdit()
|
||||||
@property (weak) IBOutlet NSTextField *url;
|
@property (weak) IBOutlet NSTextField *url;
|
||||||
@@ -34,143 +70,135 @@
|
|||||||
@property (weak) IBOutlet NSButton *warningIndicator;
|
@property (weak) IBOutlet NSButton *warningIndicator;
|
||||||
@property (weak) IBOutlet NSPopover *warningPopover;
|
@property (weak) IBOutlet NSPopover *warningPopover;
|
||||||
|
|
||||||
@property (copy) NSString *previousURL;
|
@property (copy) NSString *previousURL; // check if changed and avoid multiple download
|
||||||
@property (copy) NSString *httpDate;
|
@property (copy) NSString *httpDate;
|
||||||
@property (copy) NSString *httpEtag;
|
@property (copy) NSString *httpEtag;
|
||||||
@property (strong) NSError *feedError;
|
@property (strong) NSError *feedError; // download error or xml parser error
|
||||||
@property (strong) RSParsedFeed *feedResult;
|
@property (strong) RSParsedFeed *feedResult; // parsed result
|
||||||
|
@property (assign) BOOL didDownloadFeed; // check if feed articles need update
|
||||||
@property (assign) BOOL shouldSaveObject;
|
|
||||||
@property (assign) BOOL shouldDeletePrevArticles;
|
|
||||||
@property (assign) BOOL objectNeedsSaving;
|
|
||||||
@property (assign) BOOL objectIsModified;
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@implementation ModalFeedEdit
|
@implementation ModalFeedEdit
|
||||||
@synthesize delegate;
|
|
||||||
|
|
||||||
|
/// Init feed edit dialog with default values.
|
||||||
- (void)viewDidLoad {
|
- (void)viewDidLoad {
|
||||||
[super viewDidLoad];
|
[super viewDidLoad];
|
||||||
self.previousURL = @"";
|
self.previousURL = @"";
|
||||||
self.refreshNum.intValue = 30;
|
self.refreshNum.intValue = 30;
|
||||||
self.shouldSaveObject = NO;
|
[self populateTextFields:self.feedGroup];
|
||||||
self.shouldDeletePrevArticles = NO;
|
}
|
||||||
self.objectNeedsSaving = NO;
|
|
||||||
self.objectIsModified = NO;
|
|
||||||
|
|
||||||
FeedConfig *fc = [self feedConfigOrNil];
|
|
||||||
if (fc) {
|
|
||||||
self.url.objectValue = fc.url;
|
|
||||||
self.name.objectValue = fc.name;
|
|
||||||
self.refreshNum.intValue = fc.refreshNum;
|
|
||||||
NSInteger unitIndex = fc.refreshUnit;
|
|
||||||
if (unitIndex < 0 || unitIndex > self.refreshUnit.numberOfItems - 1)
|
|
||||||
unitIndex = self.refreshUnit.numberOfItems - 1;
|
|
||||||
[self.refreshUnit selectItemAtIndex:unitIndex];
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
Pre-fill UI control field values with @c FeedGroup properties.
|
||||||
|
*/
|
||||||
|
- (void)populateTextFields:(FeedGroup*)fg {
|
||||||
|
if (!fg || [fg hasChanges]) return; // hasChanges is true only if newly created
|
||||||
|
self.name.objectValue = fg.name;
|
||||||
|
self.url.objectValue = fg.feed.meta.url;
|
||||||
self.previousURL = self.url.stringValue;
|
self.previousURL = self.url.stringValue;
|
||||||
|
self.refreshNum.intValue = fg.feed.meta.refreshNum;
|
||||||
|
NSInteger unit = (NSInteger)fg.feed.meta.refreshUnit;
|
||||||
|
if (unit < 0 || unit > self.refreshUnit.numberOfItems - 1)
|
||||||
|
unit = self.refreshUnit.numberOfItems - 1;
|
||||||
|
[self.refreshUnit selectItemAtIndex:unit];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Edit Feed Data
|
||||||
|
|
||||||
|
/**
|
||||||
|
Use UI control field values to update the represented core data object. Also parse new articles if applicable.
|
||||||
|
Set @c scheduled to a new date if refresh interval was changed.
|
||||||
|
*/
|
||||||
|
- (void)applyChangesToCoreDataObject {
|
||||||
|
FeedMeta *meta = self.feedGroup.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]];
|
||||||
|
if (self.didDownloadFeed) {
|
||||||
|
[meta setEtag:self.httpEtag modified:self.httpDate];
|
||||||
|
[self.feedGroup.feed updateWithRSS:self.feedResult postUnreadCountChange:YES];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)dealloc {
|
/**
|
||||||
if (self.shouldSaveObject) {
|
Prepare UI (nullify @c result, @c error and start @c ProgressIndicator) and perform HTTP request.
|
||||||
if (self.objectNeedsSaving)
|
Articles will be parsed and stored in class variables.
|
||||||
[self updateRepresentedObject];
|
This should avoid unnecessary core data operations if user decides to cancel the edit.
|
||||||
FeedConfig *item = [self feedConfigOrNil];
|
The save operation will only be executed if user clicks on the 'OK' button.
|
||||||
NSUndoManager *um = item.managedObjectContext.undoManager;
|
*/
|
||||||
[um endUndoGrouping];
|
- (void)downloadRSS {
|
||||||
if (!self.objectIsModified) {
|
[self.modalSheet setDoneEnabled:NO];
|
||||||
[um disableUndoRegistration];
|
// Assuming the user has not changed title since the last fetch.
|
||||||
[um undoNestedGroup];
|
// Reset to "" because after download it will be pre-filled with new feed title
|
||||||
[um enableUndoRegistration];
|
if ([self.name.stringValue isEqualToString:self.feedResult.title]) {
|
||||||
} else {
|
self.name.stringValue = @"";
|
||||||
[self.delegate modalDidUpdateFeedConfig:item];
|
|
||||||
}
|
}
|
||||||
}
|
self.feedResult = nil;
|
||||||
}
|
self.feedError = nil;
|
||||||
|
self.httpEtag = nil;
|
||||||
|
self.httpDate = nil;
|
||||||
|
self.didDownloadFeed = NO;
|
||||||
|
[self.spinnerURL startAnimation:nil];
|
||||||
|
[self.spinnerName startAnimation:nil];
|
||||||
|
|
||||||
- (void)updateRepresentedObject {
|
[FeedDownload newFeed:self.previousURL block:^(RSParsedFeed *result, NSError *error, NSHTTPURLResponse* response) {
|
||||||
FeedConfig *item = [self feedConfigOrNil];
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
if (!item)
|
if (self.modalSheet.closeInitiated)
|
||||||
return;
|
return;
|
||||||
if (!self.shouldSaveObject) // first call to this method
|
self.didDownloadFeed = YES;
|
||||||
[item.managedObjectContext.undoManager beginUndoGrouping];
|
self.feedResult = result;
|
||||||
self.shouldSaveObject = YES;
|
self.feedError = error; // MAIN THREAD!: warning indicator .hidden is bound to feedError
|
||||||
self.objectNeedsSaving = NO; // after this method it is saved
|
self.httpEtag = [response allHeaderFields][@"Etag"];
|
||||||
|
self.httpDate = [response allHeaderFields][@"Date"]; // @"Expires", @"Last-Modified"
|
||||||
// if's to prevent unnecessary undo groups if nothing has changed
|
[self updateTextFieldURL:response.URL.absoluteString andTitle:result.title];
|
||||||
if (![item.name isEqualToString: self.name.stringValue])
|
|
||||||
item.name = self.name.stringValue;
|
|
||||||
if (![item.url isEqualToString:self.url.stringValue])
|
|
||||||
item.url = self.url.stringValue;
|
|
||||||
if (item.refreshNum != self.refreshNum.intValue)
|
|
||||||
item.refreshNum = self.refreshNum.intValue;
|
|
||||||
if (item.refreshUnit != self.refreshUnit.indexOfSelectedItem)
|
|
||||||
item.refreshUnit = (int16_t)self.refreshUnit.indexOfSelectedItem;
|
|
||||||
|
|
||||||
if (self.shouldDeletePrevArticles) {
|
|
||||||
[item updateRSSFeed:self.feedResult];
|
|
||||||
[item setEtag:self.httpEtag modified:self.httpDate];
|
|
||||||
// TODO: add icon download
|
// TODO: add icon download
|
||||||
|
// TODO: play error sound?
|
||||||
|
[self.spinnerURL stopAnimation:nil];
|
||||||
|
[self.spinnerName stopAnimation:nil];
|
||||||
|
[self.modalSheet setDoneEnabled:YES];
|
||||||
|
});
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set UI TextField values to downloaded values. Title will be updated if TextField is empty. URL on redirect.
|
||||||
|
- (void)updateTextFieldURL:(NSString*)responseURL andTitle:(NSString*)feedTitle {
|
||||||
|
// If URL was redirected (e.g., https redirect), replace original text field value with new one
|
||||||
|
if (responseURL.length > 0 && ![responseURL isEqualToString:self.previousURL]) {
|
||||||
|
self.previousURL = responseURL;
|
||||||
|
self.url.stringValue = responseURL;
|
||||||
}
|
}
|
||||||
if ([item.managedObjectContext hasChanges]) {
|
// Copy feed title to text field. (only if user hasn't set anything else yet)
|
||||||
self.objectIsModified = YES;
|
if ([self.name.stringValue isEqualToString:@""] && feedTitle.length > 0) {
|
||||||
[item calculateAndSetScheduled];
|
self.name.stringValue = feedTitle; // no damage to replace an empty string
|
||||||
[item.managedObjectContext refreshObject:item mergeChanges:YES];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
- (FeedConfig*)feedConfigOrNil {
|
#pragma mark - NSTextField Delegate
|
||||||
if ([self.representedObject isKindOfClass:[FeedConfig class]])
|
|
||||||
return self.representedObject;
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/// Helper method to check whether url was modified since last download.
|
||||||
- (BOOL)urlHasChanged {
|
- (BOOL)urlHasChanged {
|
||||||
return ![self.previousURL isEqualToString:self.url.stringValue];
|
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 {
|
- (void)controlTextDidChange:(NSNotification *)obj {
|
||||||
if (obj.object == self.url) {
|
if (obj.object == self.url) {
|
||||||
self.warningIndicator.hidden = (!self.feedError || [self urlHasChanged]);
|
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 {
|
- (void)controlTextDidEndEditing:(NSNotification *)obj {
|
||||||
if (obj.object == self.url && [self urlHasChanged]) {
|
if (obj.object == self.url && [self urlHasChanged]) {
|
||||||
self.shouldDeletePrevArticles = YES;
|
if (self.modalSheet.closeInitiated)
|
||||||
|
return;
|
||||||
self.previousURL = self.url.stringValue;
|
self.previousURL = self.url.stringValue;
|
||||||
self.feedResult = nil;
|
[self downloadRSS];
|
||||||
self.feedError = nil;
|
|
||||||
[self.spinnerURL startAnimation:nil];
|
|
||||||
[self.spinnerName startAnimation:nil];
|
|
||||||
[FeedDownload newFeed:self.previousURL block:^(RSParsedFeed *result, NSError *error, NSHTTPURLResponse* response) {
|
|
||||||
self.feedResult = result;
|
|
||||||
self.httpDate = [response allHeaderFields][@"Date"]; // @"Expires", @"Last-Modified"
|
|
||||||
self.httpEtag = [response allHeaderFields][@"Etag"];
|
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
|
||||||
if (response && ![response.URL.absoluteString isEqualToString:self.url.stringValue]) {
|
|
||||||
// URL was redirected, so replace original text field value with new one
|
|
||||||
self.url.stringValue = response.URL.absoluteString;
|
|
||||||
self.previousURL = self.url.stringValue;
|
|
||||||
}
|
|
||||||
// TODO: play error sound?
|
|
||||||
self.feedError = error; // warning indicator .hidden is bound to feedError
|
|
||||||
self.objectNeedsSaving = YES; // stays YES if this block runs after updateRepresentedObject:
|
|
||||||
[self setTitleFromFeed];
|
|
||||||
[self.spinnerURL stopAnimation:nil];
|
|
||||||
[self.spinnerName stopAnimation:nil];
|
|
||||||
});
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)setTitleFromFeed {
|
|
||||||
if ([self.name.stringValue isEqualToString:@""]) {
|
|
||||||
self.name.objectValue = self.feedResult.title;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Warning button next to url text field. Will be visible if an error occurs during download.
|
||||||
- (IBAction)didClickWarningButton:(NSButton*)sender {
|
- (IBAction)didClickWarningButton:(NSButton*)sender {
|
||||||
if (!self.feedError)
|
if (!self.feedError)
|
||||||
return;
|
return;
|
||||||
@@ -191,51 +219,49 @@
|
|||||||
@end
|
@end
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - ModalGroupEdit
|
#pragma mark - ModalGroupEdit -
|
||||||
|
|
||||||
|
|
||||||
@implementation ModalGroupEdit
|
@implementation ModalGroupEdit
|
||||||
@synthesize delegate;
|
/// Init view and set group name if edeting an already existing object.
|
||||||
- (void)viewDidLoad {
|
- (void)viewDidLoad {
|
||||||
[super viewDidLoad];
|
[super viewDidLoad];
|
||||||
if ([self.representedObject isKindOfClass:[FeedConfig class]]) {
|
if (self.feedGroup && ![self.feedGroup hasChanges]) // hasChanges is true only if newly created
|
||||||
FeedConfig *fc = self.representedObject;
|
((NSTextField*)self.view).objectValue = self.feedGroup.name;
|
||||||
((NSTextField*)self.view).objectValue = fc.name;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
/// Set one single @c NSTextField as entire view. Populate with default value and placeholder.
|
||||||
- (void)loadView {
|
- (void)loadView {
|
||||||
NSTextField *tf = [NSTextField textFieldWithString:NSLocalizedString(@"New Group", nil)];
|
NSTextField *tf = [NSTextField textFieldWithString:NSLocalizedString(@"New Group", nil)];
|
||||||
tf.placeholderString = NSLocalizedString(@"New Group", nil);
|
tf.placeholderString = NSLocalizedString(@"New Group", nil);
|
||||||
tf.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin;
|
tf.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin;
|
||||||
self.view = tf;
|
self.view = tf;
|
||||||
}
|
}
|
||||||
- (void)updateRepresentedObject {
|
/// Edit of group finished. Save changes to core data object and perform save operation on delegate.
|
||||||
if ([self.representedObject isKindOfClass:[FeedConfig class]]) {
|
- (void)applyChangesToCoreDataObject {
|
||||||
FeedConfig *item = self.representedObject;
|
|
||||||
NSString *name = ((NSTextField*)self.view).stringValue;
|
NSString *name = ((NSTextField*)self.view).stringValue;
|
||||||
if (![item.name isEqualToString: name]) {
|
if (![self.feedGroup.name isEqualToString:name])
|
||||||
item.name = name;
|
self.feedGroup.name = name;
|
||||||
[item.managedObjectContext refreshObject:item mergeChanges:YES];
|
|
||||||
[self.delegate modalDidUpdateFeedConfig:item];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@end
|
@end
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - StrictUIntFormatter
|
#pragma mark - StrictUIntFormatter -
|
||||||
|
|
||||||
|
|
||||||
@interface StrictUIntFormatter : NSFormatter
|
@interface StrictUIntFormatter : NSFormatter
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@implementation StrictUIntFormatter
|
@implementation StrictUIntFormatter
|
||||||
|
/// Display object as integer formatted string.
|
||||||
- (NSString *)stringForObjectValue:(id)obj {
|
- (NSString *)stringForObjectValue:(id)obj {
|
||||||
return [NSString stringWithFormat:@"%d", [[NSString stringWithFormat:@"%@", obj] intValue]];
|
return [NSString stringWithFormat:@"%d", [[NSString stringWithFormat:@"%@", obj] intValue]];
|
||||||
}
|
}
|
||||||
|
/// Parse any pasted input as integer.
|
||||||
- (BOOL)getObjectValue:(out id _Nullable __autoreleasing *)obj forString:(NSString *)string errorDescription:(out NSString *__autoreleasing _Nullable *)error {
|
- (BOOL)getObjectValue:(out id _Nullable __autoreleasing *)obj forString:(NSString *)string errorDescription:(out NSString *__autoreleasing _Nullable *)error {
|
||||||
*obj = [[NSNumber numberWithInt:[string intValue]] stringValue];
|
*obj = [[NSNumber numberWithInt:[string intValue]] stringValue];
|
||||||
return YES;
|
return YES;
|
||||||
}
|
}
|
||||||
|
/// Only digits, no other character allowed
|
||||||
- (BOOL)isPartialStringValid:(NSString *__autoreleasing _Nonnull *)partialStringPtr proposedSelectedRange:(NSRangePointer)proposedSelRangePtr originalString:(NSString *)origString originalSelectedRange:(NSRange)origSelRange errorDescription:(NSString *__autoreleasing _Nullable *)error {
|
- (BOOL)isPartialStringValid:(NSString *__autoreleasing _Nonnull *)partialStringPtr proposedSelectedRange:(NSRangePointer)proposedSelRangePtr originalString:(NSString *)origString originalSelectedRange:(NSRange)origSelRange errorDescription:(NSString *__autoreleasing _Nullable *)error {
|
||||||
for (NSUInteger i = 0; i < [*partialStringPtr length]; i++) {
|
for (NSUInteger i = 0; i < [*partialStringPtr length]; i++) {
|
||||||
unichar c = [*partialStringPtr characterAtIndex:i];
|
unichar c = [*partialStringPtr characterAtIndex:i];
|
||||||
|
|||||||
@@ -21,18 +21,17 @@
|
|||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
#import "SettingsFeeds.h"
|
#import "SettingsFeeds.h"
|
||||||
#import "BarMenu.h"
|
#import "Constants.h"
|
||||||
#import "ModalSheet.h"
|
|
||||||
#import "ModalFeedEdit.h"
|
|
||||||
#import "DrawImage.h"
|
#import "DrawImage.h"
|
||||||
#import "StoreCoordinator.h"
|
#import "StoreCoordinator.h"
|
||||||
#import "Constants.h"
|
#import "ModalFeedEdit.h"
|
||||||
|
#import "Feed+Ext.h"
|
||||||
|
#import "FeedGroup+Ext.h"
|
||||||
|
|
||||||
@interface SettingsFeeds () <ModalEditDelegate>
|
@interface SettingsFeeds ()
|
||||||
@property (weak) IBOutlet NSOutlineView *outlineView;
|
@property (weak) IBOutlet NSOutlineView *outlineView;
|
||||||
@property (weak) IBOutlet NSTreeController *dataStore;
|
@property (weak) IBOutlet NSTreeController *dataStore;
|
||||||
|
|
||||||
@property (strong) NSViewController<ModalFeedConfigEdit> *modalController;
|
|
||||||
@property (strong) NSArray<NSTreeNode*> *currentlyDraggedNodes;
|
@property (strong) NSArray<NSTreeNode*> *currentlyDraggedNodes;
|
||||||
@property (strong) NSUndoManager *undoManager;
|
@property (strong) NSUndoManager *undoManager;
|
||||||
@end
|
@end
|
||||||
@@ -60,22 +59,21 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
}
|
}
|
||||||
|
|
||||||
- (IBAction)addFeed:(id)sender {
|
- (IBAction)addFeed:(id)sender {
|
||||||
[self showModalForFeedConfig:nil isGroupEdit:NO];
|
[self showModalForFeedGroup:nil isGroupEdit:NO];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (IBAction)addGroup:(id)sender {
|
- (IBAction)addGroup:(id)sender {
|
||||||
[self showModalForFeedConfig:nil isGroupEdit:YES];
|
[self showModalForFeedGroup:nil isGroupEdit:YES];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (IBAction)addSeparator:(id)sender {
|
- (IBAction)addSeparator:(id)sender {
|
||||||
[self.undoManager beginUndoGrouping];
|
[self.undoManager beginUndoGrouping];
|
||||||
FeedConfig *sp = [self insertSortedItemAtSelection];
|
[self insertFeedGroupAtSelection:SEPARATOR].name = @"---";
|
||||||
sp.name = @"---";
|
|
||||||
sp.typ = SEPARATOR;
|
|
||||||
[self.undoManager endUndoGrouping];
|
[self.undoManager endUndoGrouping];
|
||||||
[self saveChanges];
|
[self saveChanges];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove user selected item from persistent store.
|
||||||
- (IBAction)remove:(id)sender {
|
- (IBAction)remove:(id)sender {
|
||||||
[self.undoManager beginUndoGrouping];
|
[self.undoManager beginUndoGrouping];
|
||||||
NSArray<NSTreeNode*> *parentNodes = [self.dataStore.selectedNodes valueForKeyPath:@"parentNode"];
|
NSArray<NSTreeNode*> *parentNodes = [self.dataStore.selectedNodes valueForKeyPath:@"parentNode"];
|
||||||
@@ -88,99 +86,104 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
|
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open user selected item for editing.
|
||||||
- (IBAction)doubleClickOutlineView:(NSOutlineView*)sender {
|
- (IBAction)doubleClickOutlineView:(NSOutlineView*)sender {
|
||||||
if (sender.clickedRow == -1)
|
if (sender.clickedRow == -1)
|
||||||
return; // ignore clicks on column headers and where no row was selected
|
return; // ignore clicks on column headers and where no row was selected
|
||||||
|
FeedGroup *fg = [(NSTreeNode*)[sender itemAtRow:sender.clickedRow] representedObject];
|
||||||
FeedConfig *fc = [(NSTreeNode*)[sender itemAtRow:sender.clickedRow] representedObject];
|
[self showModalForFeedGroup:fg isGroupEdit:YES]; // yes will be overwritten anyway
|
||||||
[self showModalForFeedConfig:fc isGroupEdit:YES]; // yes will be overwritten anyway
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - Insert & Edit Feed Items
|
#pragma mark - Insert & Edit Feed Items
|
||||||
|
|
||||||
|
|
||||||
- (void)openModalForSelection {
|
/**
|
||||||
[self showModalForFeedConfig:self.dataStore.selectedObjects.firstObject isGroupEdit:YES]; // yes will be overwritten anyway
|
Open a new modal window to edit the selected @c FeedGroup.
|
||||||
}
|
@note isGroupEdit @c flag will be overwritten if @c FeedGroup parameter is not @c nil.
|
||||||
|
|
||||||
- (void)showModalForFeedConfig:(FeedConfig*)obj isGroupEdit:(BOOL)group {
|
@param fg @c FeedGroup to be edited. If @c nil a new object will be created at the current selection.
|
||||||
BOOL existingItem = [obj isKindOfClass:[FeedConfig class]];
|
@param flag If @c YES open group edit modal dialog. If @c NO open feed edit modal dialog.
|
||||||
if (existingItem) {
|
*/
|
||||||
if (obj.typ == SEPARATOR) return;
|
- (void)showModalForFeedGroup:(FeedGroup*)fg isGroupEdit:(BOOL)flag {
|
||||||
group = (obj.typ == GROUP);
|
if (fg.typ == SEPARATOR) return;
|
||||||
}
|
|
||||||
self.modalController = (group ? [ModalGroupEdit new] : [ModalFeedEdit new]);
|
|
||||||
self.modalController.representedObject = obj;
|
|
||||||
self.modalController.delegate = self;
|
|
||||||
|
|
||||||
[self.view.window beginSheet:[ModalSheet modalWithView:self.modalController.view] completionHandler:^(NSModalResponse returnCode) {
|
|
||||||
if (returnCode == NSModalResponseOK) {
|
|
||||||
if (!existingItem) { // create new item
|
|
||||||
[self.undoManager beginUndoGrouping];
|
[self.undoManager beginUndoGrouping];
|
||||||
FeedConfig *item = [self insertSortedItemAtSelection];
|
if (!fg || ![fg isKindOfClass:[FeedGroup class]]) {
|
||||||
item.typ = (group ? GROUP : FEED);
|
fg = [self insertFeedGroupAtSelection:(flag ? GROUP : FEED)];
|
||||||
self.modalController.representedObject = item;
|
|
||||||
}
|
}
|
||||||
[self.modalController updateRepresentedObject];
|
|
||||||
if (!existingItem)
|
|
||||||
[self.undoManager endUndoGrouping];
|
|
||||||
}
|
|
||||||
self.modalController = nil;
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Called after an item was modified. May be called twice if download was still in progress.
|
ModalEditDialog *editDialog = (fg.typ == GROUP ? [ModalGroupEdit modalWith:fg] : [ModalFeedEdit modalWith:fg]);
|
||||||
- (void)modalDidUpdateFeedConfig:(FeedConfig*)config {
|
|
||||||
|
[self.view.window beginSheet:[editDialog getModalSheet] completionHandler:^(NSModalResponse returnCode) {
|
||||||
|
if (returnCode == NSModalResponseOK) {
|
||||||
|
[editDialog applyChangesToCoreDataObject];
|
||||||
|
[self.undoManager endUndoGrouping];
|
||||||
|
} else {
|
||||||
|
[self.undoManager endUndoGrouping];
|
||||||
|
[self.dataStore.managedObjectContext rollback];
|
||||||
|
}
|
||||||
|
BOOL hasChanges = [self.dataStore.managedObjectContext hasChanges];
|
||||||
|
if (hasChanges) {
|
||||||
[self saveChanges];
|
[self saveChanges];
|
||||||
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
|
[self.dataStore rearrangeObjects];
|
||||||
|
} else {
|
||||||
|
[self.undoManager disableUndoRegistration];
|
||||||
|
[self.undoManager undoNestedGroup];
|
||||||
|
[self.undoManager enableUndoRegistration];
|
||||||
|
}
|
||||||
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - Helper -
|
#pragma mark - Helper -
|
||||||
|
|
||||||
/// Insert @c FeedConfig item either after current selection or inside selected folder (if expanded)
|
/// Insert @c FeedGroup item either after current selection or inside selected folder (if expanded)
|
||||||
- (FeedConfig*)insertSortedItemAtSelection {
|
- (FeedGroup*)insertFeedGroupAtSelection:(FeedGroupType)type {
|
||||||
FeedConfig *newItem = [[FeedConfig alloc] initWithEntity:FeedConfig.entity insertIntoManagedObjectContext:self.dataStore.managedObjectContext];
|
FeedGroup *fg = [FeedGroup newGroup:type inContext:self.dataStore.managedObjectContext];
|
||||||
NSTreeNode *selection = [[self.dataStore selectedNodes] firstObject];
|
NSIndexPath *pth = [self indexPathForInsertAtNode:[[self.dataStore selectedNodes] firstObject]];
|
||||||
NSIndexPath *pth = nil;
|
[self.dataStore insertObject:fg atArrangedObjectIndexPath:pth];
|
||||||
|
|
||||||
if (!selection) { // append to root
|
if (pth.length > 1) { // some subfolder and not root folder (has parent!)
|
||||||
pth = [NSIndexPath indexPathWithIndex:[self.dataStore arrangedObjects].childNodes.count]; // or 0 to append at front
|
|
||||||
} else if ([self.outlineView isItemExpanded:selection]) { // append to group (if open)
|
|
||||||
pth = [selection.indexPath indexPathByAddingIndex:0]; // or 'selection.childNodes.count' to append at end
|
|
||||||
} else { // append before / after selected item
|
|
||||||
pth = selection.indexPath;
|
|
||||||
// remove the two lines below to insert infront of selection (instead of after selection)
|
|
||||||
NSUInteger lastIdx = [pth indexAtPosition:pth.length - 1];
|
|
||||||
pth = [[pth indexPathByRemovingLastIndex] indexPathByAddingIndex:lastIdx + 1];
|
|
||||||
}
|
|
||||||
[self.dataStore insertObject:newItem atArrangedObjectIndexPath:pth];
|
|
||||||
|
|
||||||
if (pth.length > 2) { // some subfolder; not root folder (has parent!)
|
|
||||||
NSTreeNode *parentNode = [[self.dataStore arrangedObjects] descendantNodeAtIndexPath:pth].parentNode;
|
NSTreeNode *parentNode = [[self.dataStore arrangedObjects] descendantNodeAtIndexPath:pth].parentNode;
|
||||||
newItem.parent = parentNode.representedObject;
|
fg.parent = parentNode.representedObject;
|
||||||
[self restoreOrderingAndIndexPathStr:parentNode];
|
[self restoreOrderingAndIndexPathStr:parentNode];
|
||||||
} else {
|
} else {
|
||||||
[self restoreOrderingAndIndexPathStr:[self.dataStore arrangedObjects]]; // .parent = nil
|
[self restoreOrderingAndIndexPathStr:[self.dataStore arrangedObjects]]; // .parent = nil
|
||||||
}
|
}
|
||||||
return newItem;
|
return fg;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loop over all descendants and update @c sortIndex @c (FeedConfig) as well as all @c indexPath @c (Feed)
|
/**
|
||||||
|
Index path will be selected as follow:
|
||||||
|
- @b root: append at end
|
||||||
|
- @b folder (expanded): append at front
|
||||||
|
- @b else: append after item.
|
||||||
|
|
||||||
|
@return indexPath where item will be inserted.
|
||||||
|
*/
|
||||||
|
- (NSIndexPath*)indexPathForInsertAtNode:(NSTreeNode*)node {
|
||||||
|
if (!node) { // append to root
|
||||||
|
return [NSIndexPath indexPathWithIndex:[self.dataStore arrangedObjects].childNodes.count]; // or 0 to append at front
|
||||||
|
} else if ([self.outlineView isItemExpanded:node]) { // append to group (if open)
|
||||||
|
return [node.indexPath indexPathByAddingIndex:0]; // or 'selection.childNodes.count' to append at end
|
||||||
|
} else { // append before / after selected item
|
||||||
|
NSIndexPath *pth = node.indexPath;
|
||||||
|
// remove the two lines below to insert infront of selection (instead of after selection)
|
||||||
|
NSUInteger lastIdx = [pth indexAtPosition:pth.length - 1];
|
||||||
|
return [[pth indexPathByRemovingLastIndex] indexPathByAddingIndex:lastIdx + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loop over all descendants and update @c sortIndex @c (FeedGroup) as well as all @c indexPath @c (Feed)
|
||||||
- (void)restoreOrderingAndIndexPathStr:(NSTreeNode*)parent {
|
- (void)restoreOrderingAndIndexPathStr:(NSTreeNode*)parent {
|
||||||
NSArray<NSTreeNode*> *children = parent.childNodes;
|
NSArray<NSTreeNode*> *children = parent.childNodes;
|
||||||
for (NSUInteger i = 0; i < children.count; i++) {
|
for (NSUInteger i = 0; i < children.count; i++) {
|
||||||
NSTreeNode *n = [children objectAtIndex:i];
|
FeedGroup *fg = [children objectAtIndex:i].representedObject;
|
||||||
FeedConfig *fc = n.representedObject;
|
if (fg.sortIndex != (int32_t)i)
|
||||||
// Re-calculate sort index for all affected parents
|
fg.sortIndex = (int32_t)i;
|
||||||
if (fc.sortIndex != (int32_t)i)
|
NSLog(@"%@ - %d", fg.name, fg.sortIndex);
|
||||||
fc.sortIndex = (int32_t)i;
|
[fg iterateSorted:NO overDescendantFeeds:^(Feed *feed, BOOL *cancel) {
|
||||||
// Re-calculate index path for all contained feed items
|
[feed calculateAndSetIndexPathString];
|
||||||
[fc iterateSorted:NO overDescendantFeeds:^(Feed *feed, BOOL *cancel) {
|
|
||||||
NSString *pthStr = [feed.config indexPathString];
|
|
||||||
if (![feed.indexPath isEqualToString:pthStr])
|
|
||||||
feed.indexPath = pthStr;
|
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,6 +192,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
#pragma mark - Dragging Support, Data Source Delegate
|
#pragma mark - Dragging Support, Data Source Delegate
|
||||||
|
|
||||||
|
|
||||||
|
/// Begin drag-n-drop operation by copying selected nodes to memory
|
||||||
- (BOOL)outlineView:(NSOutlineView *)outlineView writeItems:(NSArray *)items toPasteboard:(NSPasteboard *)pboard {
|
- (BOOL)outlineView:(NSOutlineView *)outlineView writeItems:(NSArray *)items toPasteboard:(NSPasteboard *)pboard {
|
||||||
[self.undoManager beginUndoGrouping];
|
[self.undoManager beginUndoGrouping];
|
||||||
[pboard declareTypes:[NSArray arrayWithObject:dragNodeType] owner:self];
|
[pboard declareTypes:[NSArray arrayWithObject:dragNodeType] owner:self];
|
||||||
@@ -197,6 +201,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
return YES;
|
return YES;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Finish drag-n-drop operation by saving changes to persistent store
|
||||||
- (void)outlineView:(NSOutlineView *)outlineView draggingSession:(NSDraggingSession *)session endedAtPoint:(NSPoint)screenPoint operation:(NSDragOperation)operation {
|
- (void)outlineView:(NSOutlineView *)outlineView draggingSession:(NSDraggingSession *)session endedAtPoint:(NSPoint)screenPoint operation:(NSDragOperation)operation {
|
||||||
[self.undoManager endUndoGrouping];
|
[self.undoManager endUndoGrouping];
|
||||||
if (self.dataStore.managedObjectContext.hasChanges) {
|
if (self.dataStore.managedObjectContext.hasChanges) {
|
||||||
@@ -209,6 +214,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
self.currentlyDraggedNodes = nil;
|
self.currentlyDraggedNodes = nil;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Perform drag-n-drop operation, move nodes to new destination and update all indices
|
||||||
- (BOOL)outlineView:(NSOutlineView *)outlineView acceptDrop:(id <NSDraggingInfo>)info item:(id)item childIndex:(NSInteger)index {
|
- (BOOL)outlineView:(NSOutlineView *)outlineView acceptDrop:(id <NSDraggingInfo>)info item:(id)item childIndex:(NSInteger)index {
|
||||||
NSTreeNode *destParent = (item != nil ? item : [self.dataStore arrangedObjects]);
|
NSTreeNode *destParent = (item != nil ? item : [self.dataStore arrangedObjects]);
|
||||||
NSUInteger idx = (NSUInteger)index;
|
NSUInteger idx = (NSUInteger)index;
|
||||||
@@ -227,13 +233,12 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
return YES;
|
return YES;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate method whether items can be dropped at destination
|
||||||
- (NSDragOperation)outlineView:(NSOutlineView *)outlineView validateDrop:(id <NSDraggingInfo>)info proposedItem:(id)item proposedChildIndex:(NSInteger)index {
|
- (NSDragOperation)outlineView:(NSOutlineView *)outlineView validateDrop:(id <NSDraggingInfo>)info proposedItem:(id)item proposedChildIndex:(NSInteger)index {
|
||||||
FeedConfig *fc = [(NSTreeNode*)item representedObject];
|
NSTreeNode *parent = item;
|
||||||
if (index == -1 && fc.typ != GROUP) { // if drag is on specific item and that item isnt a group
|
if (index == -1 && [parent isLeaf]) { // if drag is on specific item and that item isnt a group
|
||||||
return NSDragOperationNone;
|
return NSDragOperationNone;
|
||||||
}
|
}
|
||||||
|
|
||||||
NSTreeNode *parent = item;
|
|
||||||
while (parent != nil) {
|
while (parent != nil) {
|
||||||
for (NSTreeNode *node in self.currentlyDraggedNodes) {
|
for (NSTreeNode *node in self.currentlyDraggedNodes) {
|
||||||
if (parent == node)
|
if (parent == node)
|
||||||
@@ -248,10 +253,11 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
#pragma mark - Data Source Delegate
|
#pragma mark - Data Source Delegate
|
||||||
|
|
||||||
|
|
||||||
|
/// Populate @c NSOutlineView data cells with core data object values.
|
||||||
- (NSView *)outlineView:(NSOutlineView *)outlineView viewForTableColumn:(NSTableColumn *)tableColumn item:(id)item {
|
- (NSView *)outlineView:(NSOutlineView *)outlineView viewForTableColumn:(NSTableColumn *)tableColumn item:(id)item {
|
||||||
FeedConfig *f = [(NSTreeNode*)item representedObject];
|
FeedGroup *fg = [(NSTreeNode*)item representedObject];
|
||||||
BOOL isFeed = (f.typ == FEED);
|
BOOL isFeed = (fg.typ == FEED);
|
||||||
BOOL isSeperator = (f.typ == SEPARATOR);
|
BOOL isSeperator = (fg.typ == SEPARATOR);
|
||||||
BOOL isRefreshColumn = [tableColumn.identifier isEqualToString:@"RefreshColumn"];
|
BOOL isRefreshColumn = [tableColumn.identifier isEqualToString:@"RefreshColumn"];
|
||||||
|
|
||||||
NSString *cellIdent = (isRefreshColumn ? @"cellRefresh" : (isSeperator ? @"cellSeparator" : @"cellFeed"));
|
NSString *cellIdent = (isRefreshColumn ? @"cellRefresh" : (isSeperator ? @"cellSeparator" : @"cellFeed"));
|
||||||
@@ -259,12 +265,12 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
NSTableCellView *cellView = [self.outlineView makeViewWithIdentifier:cellIdent owner:nil];
|
NSTableCellView *cellView = [self.outlineView makeViewWithIdentifier:cellIdent owner:nil];
|
||||||
|
|
||||||
if (isRefreshColumn) {
|
if (isRefreshColumn) {
|
||||||
cellView.textField.stringValue = (!isFeed ? @"" : [f readableRefreshString]);
|
cellView.textField.stringValue = (isFeed && fg.refreshStr.length > 0 ? fg.refreshStr : @"");
|
||||||
} else if (isSeperator) {
|
} else if (isSeperator) {
|
||||||
return cellView; // the refresh cell is already skipped with the above if condition
|
return cellView; // the refresh cell is already skipped with the above if condition
|
||||||
} else {
|
} else {
|
||||||
cellView.textField.objectValue = f.name;
|
cellView.textField.objectValue = fg.name;
|
||||||
if (f.typ == GROUP) {
|
if (fg.typ == GROUP) {
|
||||||
cellView.imageView.image = [NSImage imageNamed:NSImageNameFolder];
|
cellView.imageView.image = [NSImage imageNamed:NSImageNameFolder];
|
||||||
} else {
|
} else {
|
||||||
// TODO: load icon
|
// TODO: load icon
|
||||||
@@ -275,8 +281,10 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
cellView.imageView.image = defaultRSSIcon;
|
cellView.imageView.image = defaultRSSIcon;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isFeed) // also for refresh column
|
if (isFeed) {// also for refresh column
|
||||||
cellView.textField.textColor = (f.refreshNum == 0 ? [NSColor disabledControlTextColor] : [NSColor controlTextColor]);
|
BOOL feedDisbaled = (fg.refreshStr.length == 0 || [fg.refreshStr characterAtIndex:0] == '0');
|
||||||
|
cellView.textField.textColor = (feedDisbaled ? [NSColor disabledControlTextColor] : [NSColor controlTextColor]);
|
||||||
|
}
|
||||||
return cellView;
|
return cellView;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +292,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
#pragma mark - Keyboard Commands: undo, redo, copy, enter
|
#pragma mark - Keyboard Commands: undo, redo, copy, enter
|
||||||
|
|
||||||
|
|
||||||
|
/// Returning @c NO will result in a Action-Not-Available-Buzzer sound
|
||||||
- (BOOL)respondsToSelector:(SEL)aSelector {
|
- (BOOL)respondsToSelector:(SEL)aSelector {
|
||||||
if (aSelector == @selector(undo:)) return [self.undoManager canUndo];
|
if (aSelector == @selector(undo:)) return [self.undoManager canUndo];
|
||||||
if (aSelector == @selector(redo:)) return [self.undoManager canRedo];
|
if (aSelector == @selector(redo:)) return [self.undoManager canRedo];
|
||||||
@@ -295,11 +304,12 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
if (aSelector == @selector(copy:))
|
if (aSelector == @selector(copy:))
|
||||||
return YES;
|
return YES;
|
||||||
// can edit only if selection is not a separator
|
// can edit only if selection is not a separator
|
||||||
return (((FeedConfig*)self.dataStore.selectedNodes.firstObject.representedObject).typ != SEPARATOR);
|
return (((FeedGroup*)self.dataStore.selectedNodes.firstObject.representedObject).typ != SEPARATOR);
|
||||||
}
|
}
|
||||||
return [super respondsToSelector:aSelector];
|
return [super respondsToSelector:aSelector];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Perform undo operation and redraw UI & menu bar unread count
|
||||||
- (void)undo:(id)sender {
|
- (void)undo:(id)sender {
|
||||||
[self.undoManager undo];
|
[self.undoManager undo];
|
||||||
[self saveChanges];
|
[self saveChanges];
|
||||||
@@ -307,6 +317,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
[self.dataStore rearrangeObjects]; // update ordering
|
[self.dataStore rearrangeObjects]; // update ordering
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Perform redo operation and redraw UI & menu bar unread count
|
||||||
- (void)redo:(id)sender {
|
- (void)redo:(id)sender {
|
||||||
[self.undoManager redo];
|
[self.undoManager redo];
|
||||||
[self saveChanges];
|
[self saveChanges];
|
||||||
@@ -314,10 +325,12 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
[self.dataStore rearrangeObjects]; // update ordering
|
[self.dataStore rearrangeObjects]; // update ordering
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// User pressed enter; open edit dialog for selected item.
|
||||||
- (void)enterPressed:(id)sender {
|
- (void)enterPressed:(id)sender {
|
||||||
[self openModalForSelection];
|
[self showModalForFeedGroup:self.dataStore.selectedObjects.firstObject isGroupEdit:YES]; // yes will be overwritten anyway
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copy human readable description of selected nodes to clipboard.
|
||||||
- (void)copy:(id)sender {
|
- (void)copy:(id)sender {
|
||||||
NSMutableString *str = [[NSMutableString alloc] init];
|
NSMutableString *str = [[NSMutableString alloc] init];
|
||||||
NSUInteger count = self.dataStore.selectedNodes.count;
|
NSUInteger count = self.dataStore.selectedNodes.count;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
</customObject>
|
</customObject>
|
||||||
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
|
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
|
||||||
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
|
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
|
||||||
<treeController mode="entity" entityName="FeedConfig" fetchPredicateFormat="parent == nil" automaticallyPreparesContent="YES" childrenKeyPath="children" leafKeyPath="type" id="JPf-gH-wxm"/>
|
<treeController mode="entity" entityName="FeedGroup" fetchPredicateFormat="parent == nil" automaticallyPreparesContent="YES" childrenKeyPath="children" leafKeyPath="type" id="JPf-gH-wxm"/>
|
||||||
<customView id="zfc-Ie-Sdx" userLabel="View">
|
<customView id="zfc-Ie-Sdx" userLabel="View">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="320" height="327"/>
|
<rect key="frame" x="0.0" y="0.0" width="320" height="327"/>
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
|||||||
@@ -25,8 +25,8 @@
|
|||||||
#import "BarMenu.h"
|
#import "BarMenu.h"
|
||||||
#import "UserPrefs.h"
|
#import "UserPrefs.h"
|
||||||
#import "StoreCoordinator.h"
|
#import "StoreCoordinator.h"
|
||||||
#import <ServiceManagement/ServiceManagement.h>
|
|
||||||
|
|
||||||
|
#import <ServiceManagement/ServiceManagement.h>
|
||||||
|
|
||||||
@interface SettingsGeneral()
|
@interface SettingsGeneral()
|
||||||
@property (weak) IBOutlet NSPopUpButton *popupHttpApplication;
|
@property (weak) IBOutlet NSPopUpButton *popupHttpApplication;
|
||||||
@@ -48,6 +48,7 @@
|
|||||||
|
|
||||||
#pragma mark - UI interaction with IBAction
|
#pragma mark - UI interaction with IBAction
|
||||||
|
|
||||||
|
/// Run helper application to add thyself to startup items.
|
||||||
- (IBAction)changeStartOnLogin:(NSButton *)sender {
|
- (IBAction)changeStartOnLogin:(NSButton *)sender {
|
||||||
// launchctl list | grep de.relikd
|
// launchctl list | grep de.relikd
|
||||||
CFStringRef helperIdentifier = CFBridgingRetain(@"de.relikd.baRSS-Helper");
|
CFStringRef helperIdentifier = CFBridgingRetain(@"de.relikd.baRSS-Helper");
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
|
|
||||||
@implementation UserPrefs
|
@implementation UserPrefs
|
||||||
|
|
||||||
|
/// @return @c YES if key is not set. Otherwise, return user defaults property from plist.
|
||||||
+ (BOOL)defaultYES:(NSString*)key {
|
+ (BOOL)defaultYES:(NSString*)key {
|
||||||
if ([[NSUserDefaults standardUserDefaults] objectForKey:key] == NULL) {
|
if ([[NSUserDefaults standardUserDefaults] objectForKey:key] == NULL) {
|
||||||
return YES;
|
return YES;
|
||||||
@@ -31,14 +32,17 @@
|
|||||||
return [[NSUserDefaults standardUserDefaults] boolForKey:key];
|
return [[NSUserDefaults standardUserDefaults] boolForKey:key];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return @c NO if key is not set. Otherwise, return user defaults property from plist.
|
||||||
+ (BOOL)defaultNO:(NSString*)key {
|
+ (BOOL)defaultNO:(NSString*)key {
|
||||||
return [[NSUserDefaults standardUserDefaults] boolForKey:key];
|
return [[NSUserDefaults standardUserDefaults] boolForKey:key];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return User configured custom browser. Or @c nil if not set yet. (which will fallback to default browser)
|
||||||
+ (NSString*)getHttpApplication {
|
+ (NSString*)getHttpApplication {
|
||||||
return [[NSUserDefaults standardUserDefaults] stringForKey:@"defaultHttpApplication"];
|
return [[NSUserDefaults standardUserDefaults] stringForKey:@"defaultHttpApplication"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Store custom browser bundle id to user defaults.
|
||||||
+ (void)setHttpApplication:(NSString*)bundleID {
|
+ (void)setHttpApplication:(NSString*)bundleID {
|
||||||
[[NSUserDefaults standardUserDefaults] setObject:bundleID forKey:@"defaultHttpApplication"];
|
[[NSUserDefaults standardUserDefaults] setObject:bundleID forKey:@"defaultHttpApplication"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
#import <Cocoa/Cocoa.h>
|
#import <Cocoa/Cocoa.h>
|
||||||
|
|
||||||
@interface ModalSheet : NSPanel
|
@interface ModalSheet : NSPanel
|
||||||
|
@property (readonly) BOOL closeInitiated;
|
||||||
|
|
||||||
+ (instancetype)modalWithView:(NSView*)content;
|
+ (instancetype)modalWithView:(NSView*)content;
|
||||||
- (void)setDoneEnabled:(BOOL)accept;
|
- (void)setDoneEnabled:(BOOL)accept;
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -27,12 +27,22 @@
|
|||||||
@end
|
@end
|
||||||
|
|
||||||
@implementation ModalSheet
|
@implementation ModalSheet
|
||||||
|
@synthesize closeInitiated = _closeInitiated;
|
||||||
|
|
||||||
|
/// User did click the 'Done' button.
|
||||||
- (void)didTapDoneButton:(id)sender { [self closeWithResponse:NSModalResponseOK]; }
|
- (void)didTapDoneButton:(id)sender { [self closeWithResponse:NSModalResponseOK]; }
|
||||||
|
/// User did click the 'Cancel' button.
|
||||||
- (void)didTapCancelButton:(id)sender { [self closeWithResponse:NSModalResponseAbort]; }
|
- (void)didTapCancelButton:(id)sender { [self closeWithResponse:NSModalResponseAbort]; }
|
||||||
|
/// Manually disable 'Done' button if a task is still running.
|
||||||
- (void)setDoneEnabled:(BOOL)accept { self.btnDone.enabled = accept; }
|
- (void)setDoneEnabled:(BOOL)accept { self.btnDone.enabled = accept; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
Called after user has clicked the 'Done' (Return) or 'Cancel' (Esc) button.
|
||||||
|
Flags controller as being closed @c .closeInitiated @c = @c YES.
|
||||||
|
And removes all subviews (clean up).
|
||||||
|
*/
|
||||||
- (void)closeWithResponse:(NSModalResponse)response {
|
- (void)closeWithResponse:(NSModalResponse)response {
|
||||||
|
_closeInitiated = YES;
|
||||||
// store modal view width and remove subviews to avoid _NSKeyboardFocusClipView issues
|
// store modal view width and remove subviews to avoid _NSKeyboardFocusClipView issues
|
||||||
// first object is always the view of the modal dialog
|
// first object is always the view of the modal dialog
|
||||||
CGFloat w = self.contentView.subviews.firstObject.frame.size.width;
|
CGFloat w = self.contentView.subviews.firstObject.frame.size.width;
|
||||||
@@ -41,6 +51,12 @@
|
|||||||
[self.sheetParent endSheet:self returnCode:response];
|
[self.sheetParent endSheet:self returnCode:response];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
Designated initializer for @c ModalSheet.
|
||||||
|
|
||||||
|
@param content @c NSView will be displayed in dialog box. 'Done' and 'Cancel' button will be added automatically.
|
||||||
|
*/
|
||||||
+ (instancetype)modalWithView:(NSView*)content {
|
+ (instancetype)modalWithView:(NSView*)content {
|
||||||
static const int padWindow = 20;
|
static const int padWindow = 20;
|
||||||
static const int padButtons = 12;
|
static const int padButtons = 12;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
|
|
||||||
@implementation Preferences
|
@implementation Preferences
|
||||||
|
|
||||||
|
/// Restore tab selection from previous session
|
||||||
- (void)windowDidLoad {
|
- (void)windowDidLoad {
|
||||||
[super windowDidLoad];
|
[super windowDidLoad];
|
||||||
NSUInteger idx = (NSUInteger)[[NSUserDefaults standardUserDefaults] integerForKey:@"preferencesTab"];
|
NSUInteger idx = (NSUInteger)[[NSUserDefaults standardUserDefaults] integerForKey:@"preferencesTab"];
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
[self tabClicked:self.window.toolbar.items[idx]];
|
[self tabClicked:self.window.toolbar.items[idx]];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace content view according to selected tab
|
||||||
- (IBAction)tabClicked:(NSToolbarItem *)sender {
|
- (IBAction)tabClicked:(NSToolbarItem *)sender {
|
||||||
self.window.contentView = nil;
|
self.window.contentView = nil;
|
||||||
if ([sender.itemIdentifier isEqualToString:@"tabGeneral"]) {
|
if ([sender.itemIdentifier isEqualToString:@"tabGeneral"]) {
|
||||||
@@ -59,7 +61,7 @@
|
|||||||
@end
|
@end
|
||||||
|
|
||||||
|
|
||||||
|
/// A window that does not respond to Cmd-C, Cmd-Z, Cmd-Shift-Z and Enter-pressed events.
|
||||||
@interface NonRespondingWindow : NSWindow
|
@interface NonRespondingWindow : NSWindow
|
||||||
@end
|
@end
|
||||||
|
|
||||||
|
|||||||
@@ -24,5 +24,5 @@
|
|||||||
|
|
||||||
@interface BarMenu : NSObject <NSMenuDelegate>
|
@interface BarMenu : NSObject <NSMenuDelegate>
|
||||||
- (void)updateBarIcon;
|
- (void)updateBarIcon;
|
||||||
- (void)reloadUnreadCountAndUpdateBarIcon;
|
- (void)asyncReloadUnreadCountAndUpdateBarIcon;
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -27,8 +27,9 @@
|
|||||||
#import "Preferences.h"
|
#import "Preferences.h"
|
||||||
#import "UserPrefs.h"
|
#import "UserPrefs.h"
|
||||||
#import "NSMenu+Ext.h"
|
#import "NSMenu+Ext.h"
|
||||||
#import "Feed+Ext.h"
|
|
||||||
#import "Constants.h"
|
#import "Constants.h"
|
||||||
|
#import "Feed+Ext.h"
|
||||||
|
#import "FeedGroup+Ext.h"
|
||||||
|
|
||||||
|
|
||||||
@interface BarMenu()
|
@interface BarMenu()
|
||||||
@@ -52,15 +53,14 @@
|
|||||||
// Unread counter
|
// Unread counter
|
||||||
self.unreadCountTotal = 0;
|
self.unreadCountTotal = 0;
|
||||||
[self updateBarIcon];
|
[self updateBarIcon];
|
||||||
[self reloadUnreadCountAndUpdateBarIcon];
|
[self asyncReloadUnreadCountAndUpdateBarIcon];
|
||||||
|
|
||||||
// Register for notifications
|
// Register for notifications
|
||||||
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedUpdated:) name:kNotificationFeedUpdated object:nil];
|
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedUpdated:) name:kNotificationFeedUpdated object:nil];
|
||||||
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(networkChanged:) name:kNotificationNetworkStatusChanged object:nil];
|
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(networkChanged:) name:kNotificationNetworkStatusChanged object:nil];
|
||||||
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(unreadCountChanged:) name:kNotificationTotalUnreadCountChanged object:nil];
|
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(unreadCountChanged:) name:kNotificationTotalUnreadCountChanged object:nil];
|
||||||
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(reloadUnreadCountAndUpdateBarIcon) name:kNotificationTotalUnreadCountReset object:nil];
|
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(asyncReloadUnreadCountAndUpdateBarIcon) name:kNotificationTotalUnreadCountReset object:nil];
|
||||||
[FeedDownload registerNetworkChangeNotification];
|
[FeedDownload registerNetworkChangeNotification]; // will call update scheduler
|
||||||
[FeedDownload performSelectorInBackground:@selector(scheduleNextUpdate:) withObject:[NSNumber numberWithBool:NO]];
|
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
#pragma mark - Update Menu Bar Icon -
|
#pragma mark - Update Menu Bar Icon -
|
||||||
|
|
||||||
/// Regardless of current unread count, perform new core data fetch on total unread count and update icon.
|
/// Regardless of current unread count, perform new core data fetch on total unread count and update icon.
|
||||||
- (void)reloadUnreadCountAndUpdateBarIcon {
|
- (void)asyncReloadUnreadCountAndUpdateBarIcon {
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
self.unreadCountTotal = [StoreCoordinator unreadCountForIndexPathString:nil];
|
self.unreadCountTotal = [StoreCoordinator unreadCountForIndexPathString:nil];
|
||||||
[self updateBarIcon];
|
[self updateBarIcon];
|
||||||
@@ -130,10 +130,10 @@
|
|||||||
// update items only if menu is already open (e.g., during background update)
|
// update items only if menu is already open (e.g., during background update)
|
||||||
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
||||||
for (NSManagedObjectID *oid in notify.object) {
|
for (NSManagedObjectID *oid in notify.object) {
|
||||||
FeedConfig *fc = [moc objectWithID:oid];
|
Feed *feed = [moc objectWithID:oid];
|
||||||
NSMenu *menu = [self fixUnreadCountForSubmenus:fc];
|
NSMenu *menu = [self fixUnreadCountForSubmenus:feed];
|
||||||
if (!menu || menu.numberOfItems > 0)
|
if (!menu || menu.numberOfItems > 0)
|
||||||
[self rebuiltFeedItems:fc.feed inMenu:menu]; // deepest menu level, feed items
|
[self rebuiltFeedArticle:feed inMenu:menu]; // deepest menu level, feed items
|
||||||
}
|
}
|
||||||
[self.barItem.menu autoEnableMenuHeader:(self.unreadCountTotal > 0)]; // once per multi-feed update
|
[self.barItem.menu autoEnableMenuHeader:(self.unreadCountTotal > 0)]; // once per multi-feed update
|
||||||
[moc reset];
|
[moc reset];
|
||||||
@@ -143,15 +143,14 @@
|
|||||||
/**
|
/**
|
||||||
Go through all parent menus and reset the menu title and unread count
|
Go through all parent menus and reset the menu title and unread count
|
||||||
|
|
||||||
@param config Should contain a @c Feed object in @c config.feed.
|
@return @c NSMenu containing @c FeedArticle. Will be @c nil if user hasn't open the menu yet.
|
||||||
@return @c NSMenu containing @c FeedItem. Will be @c nil if user hasn't open the menu yet.
|
|
||||||
*/
|
*/
|
||||||
- (nullable NSMenu*)fixUnreadCountForSubmenus:(FeedConfig*)config {
|
- (nullable NSMenu*)fixUnreadCountForSubmenus:(Feed*)feed {
|
||||||
NSMenu *menu = self.barItem.menu;
|
NSMenu *menu = self.barItem.menu;
|
||||||
for (FeedConfig *conf in [config allParents]) {
|
for (FeedGroup *parent in [feed.group allParents]) {
|
||||||
NSInteger offset = [menu feedConfigOffset];
|
NSInteger offset = [menu feedDataOffset];
|
||||||
NSMenuItem *item = [menu itemAtIndex:offset + conf.sortIndex];
|
NSMenuItem *item = [menu itemAtIndex:offset + parent.sortIndex];
|
||||||
NSInteger unread = [item setTitleAndUnreadCount:conf];
|
NSInteger unread = [item setTitleAndUnreadCount:parent];
|
||||||
menu = item.submenu;
|
menu = item.submenu;
|
||||||
if (!menu || menu.numberOfItems == 0)
|
if (!menu || menu.numberOfItems == 0)
|
||||||
return nil;
|
return nil;
|
||||||
@@ -168,17 +167,17 @@
|
|||||||
@param feed Corresponding @c Feed to @c NSMenu.
|
@param feed Corresponding @c Feed to @c NSMenu.
|
||||||
@param menu Deepest menu level which contains only feed items.
|
@param menu Deepest menu level which contains only feed items.
|
||||||
*/
|
*/
|
||||||
- (void)rebuiltFeedItems:(Feed*)feed inMenu:(NSMenu*)menu {
|
- (void)rebuiltFeedArticle:(Feed*)feed inMenu:(NSMenu*)menu {
|
||||||
if (self.currentOpenMenu != menu) {
|
if (self.currentOpenMenu != menu) {
|
||||||
// if the menu isn't open, re-create it dynamically instead
|
// if the menu isn't open, re-create it dynamically instead
|
||||||
menu.itemArray.firstObject.parentItem.submenu = [menu cleanInstanceCopy];
|
menu.itemArray.firstObject.parentItem.submenu = [menu cleanInstanceCopy];
|
||||||
} else {
|
} else {
|
||||||
[menu removeAllItems];
|
[menu removeAllItems];
|
||||||
[self insertDefaultHeaderForAllMenus:menu hasUnread:(feed.unreadCount > 0)];
|
[self insertDefaultHeaderForAllMenus:menu hasUnread:(feed.unreadCount > 0)];
|
||||||
for (FeedItem *fi in [feed sortedArticles]) {
|
for (FeedArticle *fa in [feed sortedArticles]) {
|
||||||
NSMenuItem *mi = [menu addItemWithTitle:@"" action:@selector(openFeedURL:) keyEquivalent:@""];
|
NSMenuItem *mi = [menu addItemWithTitle:@"" action:@selector(openFeedURL:) keyEquivalent:@""];
|
||||||
mi.target = self;
|
mi.target = self;
|
||||||
[mi setFeedItem:fi];
|
[mi setFeedArticle:fa];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,12 +218,12 @@
|
|||||||
/// Lazy populate system bar menus when needed.
|
/// Lazy populate system bar menus when needed.
|
||||||
- (BOOL)menu:(NSMenu*)menu updateItem:(NSMenuItem*)item atIndex:(NSInteger)index shouldCancel:(BOOL)shouldCancel {
|
- (BOOL)menu:(NSMenu*)menu updateItem:(NSMenuItem*)item atIndex:(NSInteger)index shouldCancel:(BOOL)shouldCancel {
|
||||||
id obj = [self.readContext objectWithID:[self.objectIDsForMenu objectAtIndex:(NSUInteger)index]];
|
id obj = [self.readContext objectWithID:[self.objectIDsForMenu objectAtIndex:(NSUInteger)index]];
|
||||||
if ([obj isKindOfClass:[FeedConfig class]]) {
|
if ([obj isKindOfClass:[FeedGroup class]]) {
|
||||||
[item setFeedConfig:obj];
|
[item setFeedGroup:obj];
|
||||||
if ([(FeedConfig*)obj typ] == FEED)
|
if ([(FeedGroup*)obj typ] == FEED)
|
||||||
[item setTarget:self action:@selector(openFeedURL:)];
|
[item setTarget:self action:@selector(openFeedURL:)];
|
||||||
} else if ([obj isKindOfClass:[FeedItem class]]) {
|
} else if ([obj isKindOfClass:[FeedArticle class]]) {
|
||||||
[item setFeedItem:obj];
|
[item setFeedArticle:obj];
|
||||||
[item setTarget:self action:@selector(openFeedURL:)];
|
[item setTarget:self action:@selector(openFeedURL:)];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +242,7 @@
|
|||||||
- (void)finalizeMenu:(NSMenu*)menu object:(id)obj {
|
- (void)finalizeMenu:(NSMenu*)menu object:(id)obj {
|
||||||
NSInteger unreadCount = self.unreadCountTotal; // if parent == nil
|
NSInteger unreadCount = self.unreadCountTotal; // if parent == nil
|
||||||
if ([menu isFeedMenu]) {
|
if ([menu isFeedMenu]) {
|
||||||
unreadCount = [(FeedItem*)obj feed].unreadCount;
|
unreadCount = ((FeedArticle*)obj).feed.unreadCount;
|
||||||
} else if (![menu isMainMenu]) {
|
} else if (![menu isMainMenu]) {
|
||||||
unreadCount = [menu coreDataUnreadCount];
|
unreadCount = [menu coreDataUnreadCount];
|
||||||
}
|
}
|
||||||
@@ -326,6 +325,7 @@
|
|||||||
- (void)preferencesClosed:(id)sender {
|
- (void)preferencesClosed:(id)sender {
|
||||||
[[NSNotificationCenter defaultCenter] removeObserver:self name:NSWindowWillCloseNotification object:self.prefWindow.window];
|
[[NSNotificationCenter defaultCenter] removeObserver:self name:NSWindowWillCloseNotification object:self.prefWindow.window];
|
||||||
self.prefWindow = nil;
|
self.prefWindow = nil;
|
||||||
|
[FeedDownload scheduleNextUpdateForced:NO];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -340,7 +340,7 @@
|
|||||||
*/
|
*/
|
||||||
- (void)updateAllFeeds:(NSMenuItem*)sender {
|
- (void)updateAllFeeds:(NSMenuItem*)sender {
|
||||||
// TODO: Disable 'update all' menu item during update?
|
// TODO: Disable 'update all' menu item during update?
|
||||||
[FeedDownload scheduleNextUpdate:YES];
|
[FeedDownload scheduleNextUpdateForced:YES];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -354,11 +354,11 @@
|
|||||||
|
|
||||||
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
||||||
[sender iterateSorted:YES inContext:moc overDescendentFeeds:^(Feed *feed, BOOL *cancel) {
|
[sender iterateSorted:YES inContext:moc overDescendentFeeds:^(Feed *feed, BOOL *cancel) {
|
||||||
for (FeedItem *i in [feed sortedArticles]) { // TODO: open oldest articles first?
|
for (FeedArticle *fa in [feed sortedArticles]) { // TODO: open oldest articles first?
|
||||||
if (maxItemCount <= 0) break;
|
if (maxItemCount <= 0) break;
|
||||||
if (i.unread && i.link.length > 0) {
|
if (fa.unread && fa.link.length > 0) {
|
||||||
[urls addObject:[NSURL URLWithString:i.link]];
|
[urls addObject:[NSURL URLWithString:fa.link]];
|
||||||
i.unread = NO;
|
fa.unread = NO;
|
||||||
feed.unreadCount -= 1;
|
feed.unreadCount -= 1;
|
||||||
self.unreadCountTotal -= 1;
|
self.unreadCountTotal -= 1;
|
||||||
maxItemCount -= 1;
|
maxItemCount -= 1;
|
||||||
@@ -389,7 +389,7 @@
|
|||||||
/**
|
/**
|
||||||
Called when user clicks on a single feed item or the feed group.
|
Called when user clicks on a single feed item or the feed group.
|
||||||
|
|
||||||
@param sender A menu item containing either a @c FeedItem or a @c FeedConfig objectID.
|
@param sender A menu item containing either a @c FeedArticle or a @c FeedGroup objectID.
|
||||||
*/
|
*/
|
||||||
- (void)openFeedURL:(NSMenuItem*)sender {
|
- (void)openFeedURL:(NSMenuItem*)sender {
|
||||||
NSManagedObjectID *oid = sender.representedObject;
|
NSManagedObjectID *oid = sender.representedObject;
|
||||||
@@ -398,14 +398,14 @@
|
|||||||
NSString *url = nil;
|
NSString *url = nil;
|
||||||
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
||||||
id obj = [moc objectWithID:oid];
|
id obj = [moc objectWithID:oid];
|
||||||
if ([obj isKindOfClass:[FeedConfig class]]) {
|
if ([obj isKindOfClass:[FeedGroup class]]) {
|
||||||
url = [[(FeedConfig*)obj feed] link];
|
url = ((FeedGroup*)obj).feed.link;
|
||||||
} else if ([obj isKindOfClass:[FeedItem class]]) {
|
} else if ([obj isKindOfClass:[FeedArticle class]]) {
|
||||||
FeedItem *item = obj;
|
FeedArticle *fa = obj;
|
||||||
url = [item link];
|
url = fa.link;
|
||||||
if (item.unread) {
|
if (fa.unread) {
|
||||||
item.unread = NO;
|
fa.unread = NO;
|
||||||
item.feed.unreadCount -= 1;
|
fa.feed.unreadCount -= 1;
|
||||||
self.unreadCountTotal -= 1;
|
self.unreadCountTotal -= 1;
|
||||||
[self updateBarIcon];
|
[self updateBarIcon];
|
||||||
[StoreCoordinator saveContext:moc andParent:YES];
|
[StoreCoordinator saveContext:moc andParent:YES];
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
- (BOOL)isMainMenu;
|
- (BOOL)isMainMenu;
|
||||||
- (BOOL)isFeedMenu;
|
- (BOOL)isFeedMenu;
|
||||||
- (MenuItemTag)scope;
|
- (MenuItemTag)scope;
|
||||||
- (NSInteger)feedConfigOffset;
|
- (NSInteger)feedDataOffset;
|
||||||
- (NSInteger)coreDataUnreadCount;
|
- (NSInteger)coreDataUnreadCount;
|
||||||
// Modify menu
|
// Modify menu
|
||||||
- (void)replaceSeparatorStringsWithActualSeparator;
|
- (void)replaceSeparatorStringsWithActualSeparator;
|
||||||
|
|||||||
@@ -70,8 +70,8 @@
|
|||||||
return ScopeGroup;
|
return ScopeGroup;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @return Index offset of the first Core Data feed item (may be separator), skipping default header and main menu header.
|
/// @return Index offset of the first core data feed item (may be separator), skipping default header and main menu header.
|
||||||
- (NSInteger)feedConfigOffset {
|
- (NSInteger)feedDataOffset {
|
||||||
for (NSInteger i = 0; i < self.numberOfItems; i++) {
|
for (NSInteger i = 0; i < self.numberOfItems; i++) {
|
||||||
if ([[[self itemAtIndex:i] representedObject] isKindOfClass:[NSManagedObjectID class]])
|
if ([[[self itemAtIndex:i] representedObject] isKindOfClass:[NSManagedObjectID class]])
|
||||||
return i;
|
return i;
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Perform Core Data fetch request and return unread count for all descendent items.
|
/// Perform core data fetch request and return unread count for all descendent items.
|
||||||
- (NSInteger)coreDataUnreadCount {
|
- (NSInteger)coreDataUnreadCount {
|
||||||
NSUInteger loc = [self.title rangeOfString:@"."].location;
|
NSUInteger loc = [self.title rangeOfString:@"."].location;
|
||||||
NSString *path = nil;
|
NSString *path = nil;
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ static NSString *kSeparatorItemTitle = @"---SEPARATOR---";
|
|||||||
|
|
||||||
/// @c NSMenuItem options that are assigned to the @c tag attribute.
|
/// @c NSMenuItem options that are assigned to the @c tag attribute.
|
||||||
typedef NS_OPTIONS(NSInteger, MenuItemTag) {
|
typedef NS_OPTIONS(NSInteger, MenuItemTag) {
|
||||||
/// Item visible at the very first menu level
|
/// Item visible at the very first menu level @c (StatusBar)
|
||||||
ScopeGlobal = 2,
|
ScopeGlobal = 2,
|
||||||
/// Item visible at each grouping, e.g., multiple feeds in one group
|
/// Item visible at each group, e.g., multiple feeds in one group
|
||||||
ScopeGroup = 4,
|
ScopeGroup = 4,
|
||||||
/// Item visible at the deepest menu level (@c FeedItem elements and header)
|
/// Item visible at the deepest menu level @c (FeedArticle)
|
||||||
ScopeFeed = 8,
|
ScopeFeed = 8,
|
||||||
///
|
///
|
||||||
TagPreferences = (1 << 4),
|
TagPreferences = (1 << 4),
|
||||||
@@ -44,16 +44,16 @@ typedef NS_OPTIONS(NSInteger, MenuItemTag) {
|
|||||||
TagMaskType = 0xFFF0,
|
TagMaskType = 0xFFF0,
|
||||||
};
|
};
|
||||||
|
|
||||||
@class FeedConfig, Feed, FeedItem;
|
@class FeedGroup, Feed, FeedArticle;
|
||||||
|
|
||||||
@interface NSMenuItem (Feed)
|
@interface NSMenuItem (Feed)
|
||||||
+ (NSMenuItem*)itemWithTitle:(NSString*)title action:(SEL)selector target:(id)target tag:(MenuItemTag)tag;
|
+ (NSMenuItem*)itemWithTitle:(NSString*)title action:(SEL)selector target:(id)target tag:(MenuItemTag)tag;
|
||||||
- (NSMenuItem*)alternateWithTitle:(NSString*)title;
|
- (NSMenuItem*)alternateWithTitle:(NSString*)title;
|
||||||
- (void)setTarget:(id)target action:(SEL)selector;
|
- (void)setTarget:(id)target action:(SEL)selector;
|
||||||
|
|
||||||
- (void)setFeedConfig:(FeedConfig*)config;
|
- (void)setFeedGroup:(FeedGroup*)group;
|
||||||
- (void)setFeedItem:(FeedItem*)item;
|
- (void)setFeedArticle:(FeedArticle*)article;
|
||||||
- (NSInteger)setTitleAndUnreadCount:(FeedConfig*)config;
|
- (NSInteger)setTitleAndUnreadCount:(FeedGroup*)group;
|
||||||
|
|
||||||
- (void)iterateSorted:(BOOL)ordered inContext:(NSManagedObjectContext*)moc overDescendentFeeds:(void(^)(Feed *feed, BOOL *cancel))block;
|
- (void)iterateSorted:(BOOL)ordered inContext:(NSManagedObjectContext*)moc overDescendentFeeds:(void(^)(Feed *feed, BOOL *cancel))block;
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
#import "StoreCoordinator.h"
|
#import "StoreCoordinator.h"
|
||||||
#import "DrawImage.h"
|
#import "DrawImage.h"
|
||||||
#import "UserPrefs.h"
|
#import "UserPrefs.h"
|
||||||
|
#import "FeedGroup+Ext.h"
|
||||||
|
|
||||||
/// User preferences for displaying menu items
|
/// User preferences for displaying menu items
|
||||||
typedef NS_ENUM(char, DisplaySetting) {
|
typedef NS_ENUM(char, DisplaySetting) {
|
||||||
@@ -83,42 +84,42 @@ typedef NS_ENUM(char, DisplaySetting) {
|
|||||||
|
|
||||||
@return Number of unread items. (@b warning: May return @c 0 if visibility is disabled in @c UserPrefs)
|
@return Number of unread items. (@b warning: May return @c 0 if visibility is disabled in @c UserPrefs)
|
||||||
*/
|
*/
|
||||||
- (NSInteger)setTitleAndUnreadCount:(FeedConfig*)config {
|
- (NSInteger)setTitleAndUnreadCount:(FeedGroup*)fg {
|
||||||
NSInteger uCount = 0;
|
NSInteger uCount = 0;
|
||||||
if (config.typ == FEED && [UserPrefs defaultYES:@"feedUnreadCount"]) {
|
if (fg.typ == FEED && [UserPrefs defaultYES:@"feedUnreadCount"]) {
|
||||||
uCount = config.feed.unreadCount;
|
uCount = fg.feed.unreadCount;
|
||||||
} else if (config.typ == GROUP && [UserPrefs defaultYES:@"groupUnreadCount"]) {
|
} else if (fg.typ == GROUP && [UserPrefs defaultYES:@"groupUnreadCount"]) {
|
||||||
uCount = [self.submenu coreDataUnreadCount];
|
uCount = [self.submenu coreDataUnreadCount];
|
||||||
}
|
}
|
||||||
self.title = (uCount > 0 ? [NSString stringWithFormat:@"%@ (%ld)", config.name, uCount] : config.name);
|
self.title = (uCount > 0 ? [NSString stringWithFormat:@"%@ (%ld)", fg.name, uCount] : fg.name);
|
||||||
return uCount;
|
return uCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Fully configures a Separator item OR group item OR feed item. (but not @c FeedItem item)
|
Fully configures a Separator item OR group item OR feed item. (but not @c FeedArticle item)
|
||||||
*/
|
*/
|
||||||
- (void)setFeedConfig:(FeedConfig*)config {
|
- (void)setFeedGroup:(FeedGroup*)fg {
|
||||||
self.representedObject = config.objectID;
|
self.representedObject = fg.objectID;
|
||||||
if (config.typ == SEPARATOR) {
|
if (fg.typ == SEPARATOR) {
|
||||||
self.title = kSeparatorItemTitle;
|
self.title = kSeparatorItemTitle;
|
||||||
} else {
|
} else {
|
||||||
self.submenu = [self.menu submenuWithIndex:config.sortIndex isFeed:(config.typ == FEED)];
|
self.submenu = [self.menu submenuWithIndex:fg.sortIndex isFeed:(fg.typ == FEED)];
|
||||||
[self setTitleAndUnreadCount:config]; // after submenu is set
|
[self setTitleAndUnreadCount:fg]; // after submenu is set
|
||||||
if (config.typ == FEED) {
|
if (fg.typ == FEED) {
|
||||||
[self configureAsFeed:config];
|
[self configureAsFeed:fg];
|
||||||
} else {
|
} else {
|
||||||
[self configureAsGroup:config];
|
[self configureAsGroup:fg];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Configure menu item to be used as a container for @c FeedItem entries (incl. feed icon).
|
Configure menu item to be used as a container for @c FeedArticle entries (incl. feed icon).
|
||||||
*/
|
*/
|
||||||
- (void)configureAsFeed:(FeedConfig*)config {
|
- (void)configureAsFeed:(FeedGroup*)fg {
|
||||||
self.tag = ScopeFeed;
|
self.tag = ScopeFeed;
|
||||||
self.toolTip = config.feed.subtitle;
|
self.toolTip = fg.feed.subtitle;
|
||||||
self.enabled = (config.feed.items.count > 0);
|
self.enabled = (fg.feed.articles.count > 0);
|
||||||
// set icon
|
// set icon
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
static NSImage *defaultRSSIcon;
|
static NSImage *defaultRSSIcon;
|
||||||
@@ -131,9 +132,9 @@ typedef NS_ENUM(char, DisplaySetting) {
|
|||||||
/**
|
/**
|
||||||
Configure menu item to be used as a container for multiple feeds.
|
Configure menu item to be used as a container for multiple feeds.
|
||||||
*/
|
*/
|
||||||
- (void)configureAsGroup:(FeedConfig*)config {
|
- (void)configureAsGroup:(FeedGroup*)fg {
|
||||||
self.tag = ScopeGroup;
|
self.tag = ScopeGroup;
|
||||||
self.enabled = (config.children.count > 0);
|
self.enabled = (fg.children.count > 0);
|
||||||
// set icon
|
// set icon
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
static NSImage *groupIcon;
|
static NSImage *groupIcon;
|
||||||
@@ -146,50 +147,50 @@ typedef NS_ENUM(char, DisplaySetting) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Populate @c NSMenuItem based on the attributes of a @c FeedItem.
|
Populate @c NSMenuItem based on the attributes of a @c FeedArticle.
|
||||||
*/
|
*/
|
||||||
- (void)setFeedItem:(FeedItem*)item {
|
- (void)setFeedArticle:(FeedArticle*)fa {
|
||||||
self.title = item.title;
|
self.title = fa.title;
|
||||||
self.tag = ScopeFeed;
|
self.tag = ScopeFeed;
|
||||||
self.enabled = (item.link.length > 0);
|
self.enabled = (fa.link.length > 0);
|
||||||
self.state = (item.unread ? NSControlStateValueOn : NSControlStateValueOff);
|
self.state = (fa.unread ? NSControlStateValueOn : NSControlStateValueOff);
|
||||||
self.representedObject = item.objectID;
|
self.representedObject = fa.objectID;
|
||||||
//mi.toolTip = item.abstract;
|
//mi.toolTip = item.abstract;
|
||||||
// TODO: Do regex during save, not during display. Its here for testing purposes ...
|
// TODO: Do regex during save, not during display. Its here for testing purposes ...
|
||||||
if (item.abstract.length > 0) {
|
if (fa.abstract.length > 0) {
|
||||||
NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"<[^>]*>" options:kNilOptions error:nil];
|
NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"<[^>]*>" options:kNilOptions error:nil];
|
||||||
self.toolTip = [regex stringByReplacingMatchesInString:item.abstract options:kNilOptions range:NSMakeRange(0, item.abstract.length) withTemplate:@""];
|
self.toolTip = [regex stringByReplacingMatchesInString:fa.abstract options:kNilOptions range:NSMakeRange(0, fa.abstract.length) withTemplate:@""];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - Helper -
|
#pragma mark - Helper -
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@return @c FeedConfig object if @c representedObject contains a valid @c NSManagedObjectID.
|
@return @c FeedGroup object if @c representedObject contains a valid @c NSManagedObjectID.
|
||||||
*/
|
*/
|
||||||
- (FeedConfig*)requestConfig:(NSManagedObjectContext*)moc {
|
- (FeedGroup*)requestGroup:(NSManagedObjectContext*)moc {
|
||||||
if (!self.representedObject || ![self.representedObject isKindOfClass:[NSManagedObjectID class]])
|
if (!self.representedObject || ![self.representedObject isKindOfClass:[NSManagedObjectID class]])
|
||||||
return nil;
|
return nil;
|
||||||
FeedConfig *config = [moc objectWithID:self.representedObject];
|
FeedGroup *fg = [moc objectWithID:self.representedObject];
|
||||||
if (![config isKindOfClass:[FeedConfig class]])
|
if (![fg isKindOfClass:[FeedGroup class]])
|
||||||
return nil;
|
return nil;
|
||||||
return config;
|
return fg;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Perform @c block on every @c FeedConfig in the items menu or any of its submenues.
|
Perform @c block on every @c FeedGroup in the items menu or any of its submenues.
|
||||||
|
|
||||||
@param ordered Whether order matters or not. If all items are processed anyway, pass @c NO for a speedup.
|
@param ordered Whether order matters or not. If all items are processed anyway, pass @c NO for a speedup.
|
||||||
@param block Set cancel to @c YES to stop enumeration early.
|
@param block Set cancel to @c YES to stop enumeration early.
|
||||||
*/
|
*/
|
||||||
- (void)iterateSorted:(BOOL)ordered inContext:(NSManagedObjectContext*)moc overDescendentFeeds:(void(^)(Feed*,BOOL*))block {
|
- (void)iterateSorted:(BOOL)ordered inContext:(NSManagedObjectContext*)moc overDescendentFeeds:(void(^)(Feed*,BOOL*))block {
|
||||||
if (self.parentItem) {
|
if (self.parentItem) {
|
||||||
[[self.parentItem requestConfig:moc] iterateSorted:ordered overDescendantFeeds:block];
|
[[self.parentItem requestGroup:moc] iterateSorted:ordered overDescendantFeeds:block];
|
||||||
} else {
|
} else {
|
||||||
for (NSMenuItem *item in self.menu.itemArray) {
|
for (NSMenuItem *item in self.menu.itemArray) {
|
||||||
FeedConfig *fc = [item requestConfig:moc];
|
FeedGroup *fg = [item requestGroup:moc];
|
||||||
if (fc != nil) { // All groups and feeds; Ignore default header
|
if (fg != nil) { // All groups and feeds; Ignore default header
|
||||||
if (![fc iterateSorted:ordered overDescendantFeeds:block])
|
if (![fg iterateSorted:ordered overDescendantFeeds:block])
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,16 +22,13 @@
|
|||||||
|
|
||||||
#import <Foundation/Foundation.h>
|
#import <Foundation/Foundation.h>
|
||||||
#import "DBv1+CoreDataModel.h"
|
#import "DBv1+CoreDataModel.h"
|
||||||
#import "FeedConfig+Ext.h"
|
|
||||||
|
|
||||||
@class RSParsedFeed;
|
|
||||||
|
|
||||||
@interface StoreCoordinator : NSObject
|
@interface StoreCoordinator : NSObject
|
||||||
// Managing contexts
|
// Managing contexts
|
||||||
+ (NSManagedObjectContext*)createChildContext;
|
+ (NSManagedObjectContext*)createChildContext;
|
||||||
+ (void)saveContext:(NSManagedObjectContext*)context andParent:(BOOL)flag;
|
+ (void)saveContext:(NSManagedObjectContext*)context andParent:(BOOL)flag;
|
||||||
// Feed update
|
// Feed update
|
||||||
+ (NSArray<FeedConfig*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc;
|
+ (NSArray<Feed*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc;
|
||||||
+ (NSDate*)nextScheduledUpdate;
|
+ (NSDate*)nextScheduledUpdate;
|
||||||
// Feed display
|
// Feed display
|
||||||
+ (NSInteger)unreadCountForIndexPathString:(NSString*)str;
|
+ (NSInteger)unreadCountForIndexPathString:(NSString*)str;
|
||||||
|
|||||||
@@ -22,16 +22,24 @@
|
|||||||
|
|
||||||
#import "StoreCoordinator.h"
|
#import "StoreCoordinator.h"
|
||||||
#import "AppHook.h"
|
#import "AppHook.h"
|
||||||
|
#import "Feed+Ext.h"
|
||||||
|
|
||||||
#import <RSXML/RSXML.h>
|
#import <RSXML/RSXML.h>
|
||||||
|
|
||||||
@implementation StoreCoordinator
|
@implementation StoreCoordinator
|
||||||
|
|
||||||
#pragma mark - Managing contexts -
|
#pragma mark - Managing contexts -
|
||||||
|
|
||||||
|
/**
|
||||||
|
@return The application main persistent context.
|
||||||
|
*/
|
||||||
+ (NSManagedObjectContext*)getMainContext {
|
+ (NSManagedObjectContext*)getMainContext {
|
||||||
return [(AppHook*)NSApp persistentContainer].viewContext;
|
return [(AppHook*)NSApp persistentContainer].viewContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
New child context with @c NSMainQueueConcurrencyType and without undo manager.
|
||||||
|
*/
|
||||||
+ (NSManagedObjectContext*)createChildContext {
|
+ (NSManagedObjectContext*)createChildContext {
|
||||||
NSManagedObjectContext *context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType];
|
NSManagedObjectContext *context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType];
|
||||||
[context setParentContext:[self getMainContext]];
|
[context setParentContext:[self getMainContext]];
|
||||||
@@ -40,6 +48,11 @@
|
|||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Commit changes and perform save operation on @c context.
|
||||||
|
|
||||||
|
@param flag If @c YES save any parent context (recursive).
|
||||||
|
*/
|
||||||
+ (void)saveContext:(NSManagedObjectContext*)context andParent:(BOOL)flag {
|
+ (void)saveContext:(NSManagedObjectContext*)context andParent:(BOOL)flag {
|
||||||
// Performs the save action for the application, which is to send the save: message to the application's managed object context. Any encountered errors are presented to the user.
|
// Performs the save action for the application, which is to send the save: message to the application's managed object context. Any encountered errors are presented to the user.
|
||||||
if (![context commitEditing]) {
|
if (![context commitEditing]) {
|
||||||
@@ -57,14 +70,16 @@
|
|||||||
|
|
||||||
#pragma mark - Feed Update -
|
#pragma mark - Feed Update -
|
||||||
|
|
||||||
+ (NSArray<FeedConfig*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc {
|
/**
|
||||||
// TODO: Get Feed instead of FeedConfig
|
List of @c Feed items that need to be updated. Scheduled time is now (or in past).
|
||||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedConfig.entity.name];
|
|
||||||
|
@param forceAll If @c YES get a list of all @c Feed regardless of schedules time.
|
||||||
|
*/
|
||||||
|
+ (NSArray<Feed*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc {
|
||||||
|
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
|
||||||
if (!forceAll) {
|
if (!forceAll) {
|
||||||
// when fetching also get those feeds that would need update soon (now + 30s)
|
// when fetching also get those feeds that would need update soon (now + 30s)
|
||||||
fr.predicate = [NSPredicate predicateWithFormat:@"type = %d AND scheduled <= %@", FEED, [NSDate dateWithTimeIntervalSinceNow:+30]];
|
fr.predicate = [NSPredicate predicateWithFormat:@"meta.scheduled <= %@", [NSDate dateWithTimeIntervalSinceNow:+30]];
|
||||||
} else {
|
|
||||||
fr.predicate = [NSPredicate predicateWithFormat:@"type = %d", FEED];
|
|
||||||
}
|
}
|
||||||
NSError *err;
|
NSError *err;
|
||||||
NSArray *result = [moc executeFetchRequest:fr error:&err];
|
NSArray *result = [moc executeFetchRequest:fr error:&err];
|
||||||
@@ -72,8 +87,11 @@
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
@return @c NSDate of next (earliest) feed update. May be @c nil.
|
||||||
|
*/
|
||||||
+ (NSDate*)nextScheduledUpdate {
|
+ (NSDate*)nextScheduledUpdate {
|
||||||
// Always get context first, or 'FeedConfig.entity.name' may not be available on app start
|
// Always get context first, or 'FeedMeta.entity.name' may not be available on app start
|
||||||
NSManagedObjectContext *moc = [self getMainContext];
|
NSManagedObjectContext *moc = [self getMainContext];
|
||||||
NSExpression *exp = [NSExpression expressionForFunction:@"min:"
|
NSExpression *exp = [NSExpression expressionForFunction:@"min:"
|
||||||
arguments:@[[NSExpression expressionForKeyPath:@"scheduled"]]];
|
arguments:@[[NSExpression expressionForKeyPath:@"scheduled"]]];
|
||||||
@@ -82,8 +100,7 @@
|
|||||||
[expDesc setExpression:exp];
|
[expDesc setExpression:exp];
|
||||||
[expDesc setExpressionResultType:NSDateAttributeType];
|
[expDesc setExpressionResultType:NSDateAttributeType];
|
||||||
|
|
||||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedConfig.entity.name];
|
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedMeta.entity.name];
|
||||||
fr.predicate = [NSPredicate predicateWithFormat:@"type = %d", FEED];
|
|
||||||
[fr setResultType:NSDictionaryResultType];
|
[fr setResultType:NSDictionaryResultType];
|
||||||
[fr setPropertiesToFetch:@[expDesc]];
|
[fr setPropertiesToFetch:@[expDesc]];
|
||||||
|
|
||||||
@@ -95,8 +112,13 @@
|
|||||||
|
|
||||||
#pragma mark - Feed Display -
|
#pragma mark - Feed Display -
|
||||||
|
|
||||||
|
/**
|
||||||
|
Perform core data fetch request with sum over all unread feeds matching @c str.
|
||||||
|
|
||||||
|
@param str A dot separated string of integer index parts.
|
||||||
|
*/
|
||||||
+ (NSInteger)unreadCountForIndexPathString:(NSString*)str {
|
+ (NSInteger)unreadCountForIndexPathString:(NSString*)str {
|
||||||
// Always get context first, or 'FeedConfig.entity.name' may not be available on app start
|
// Always get context first, or 'Feed.entity.name' may not be available on app start
|
||||||
NSManagedObjectContext *moc = [self getMainContext];
|
NSManagedObjectContext *moc = [self getMainContext];
|
||||||
NSExpression *exp = [NSExpression expressionForFunction:@"sum:"
|
NSExpression *exp = [NSExpression expressionForFunction:@"sum:"
|
||||||
arguments:@[[NSExpression expressionForKeyPath:@"unreadCount"]]];
|
arguments:@[[NSExpression expressionForKeyPath:@"unreadCount"]]];
|
||||||
@@ -117,10 +139,16 @@
|
|||||||
return [fetchResults.firstObject[@"totalUnread"] integerValue];
|
return [fetchResults.firstObject[@"totalUnread"] integerValue];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Get sorted list of @c ObjectIDs for either @c FeedGroup or @c FeedArticle.
|
||||||
|
|
||||||
|
@param parent Either @c ObjectID or actual object. Or @c nil for root folder.
|
||||||
|
@param flag If @c YES request list of @c FeedArticle instead of @c FeedGroup
|
||||||
|
*/
|
||||||
+ (NSArray*)sortedObjectIDsForParent:(id)parent isFeed:(BOOL)flag inContext:(NSManagedObjectContext*)moc {
|
+ (NSArray*)sortedObjectIDsForParent:(id)parent isFeed:(BOOL)flag inContext:(NSManagedObjectContext*)moc {
|
||||||
// NSManagedObjectContext *moc = [self getMainContext];
|
// NSManagedObjectContext *moc = [self getMainContext];
|
||||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: (flag ? FeedItem.entity : FeedConfig.entity).name];
|
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: (flag ? FeedArticle.entity : FeedGroup.entity).name];
|
||||||
fr.predicate = [NSPredicate predicateWithFormat:(flag ? @"feed.config = %@" : @"parent = %@"), parent];
|
fr.predicate = [NSPredicate predicateWithFormat:(flag ? @"feed.group = %@" : @"parent = %@"), parent];
|
||||||
fr.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:!flag]];
|
fr.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:!flag]];
|
||||||
[fr setResultType:NSManagedObjectIDResultType];
|
[fr setResultType:NSManagedObjectIDResultType];
|
||||||
|
|
||||||
@@ -130,30 +158,24 @@
|
|||||||
return fetchResults;
|
return fetchResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
//+ (void)addToSortIndex:(int)num start:(int)index parent:(FeedConfig*)config inContext:(NSManagedObjectContext*)moc {
|
|
||||||
// NSBatchUpdateRequest *ur = [[NSBatchUpdateRequest alloc] initWithEntityName: FeedConfig.entity.name];
|
|
||||||
// ur.predicate = [NSPredicate predicateWithFormat:@"parent = %@ AND sortIndex >= %d", config, index];
|
|
||||||
// ur.propertiesToUpdate = @{@"sortIndex": [NSExpression expressionWithFormat: @"sortIndex + %d", num]};
|
|
||||||
// ur.resultType = NSUpdatedObjectsCountResultType;//NSUpdatedObjectIDsResultType;//NSStatusOnlyResultType;
|
|
||||||
// NSError *err;
|
|
||||||
// NSBatchUpdateResult *result = [moc executeRequest:ur error:&err];
|
|
||||||
// if (err) NSLog(@"%@", err);
|
|
||||||
// NSLog(@"Result: %@", result.result);
|
|
||||||
// //[NSManagedObjectContext mergeChangesFromRemoteContextSave:@{NSUpdatedObjectsKey : result.result} intoContexts:@[moc]];
|
|
||||||
//}
|
|
||||||
|
|
||||||
#pragma mark - Restore Sound State -
|
#pragma mark - Restore Sound State -
|
||||||
|
|
||||||
|
/**
|
||||||
|
Delete all @c Feed items where @c group @c = @c NULL.
|
||||||
|
*/
|
||||||
+ (void)deleteUnreferencedFeeds {
|
+ (void)deleteUnreferencedFeeds {
|
||||||
NSManagedObjectContext *moc = [self getMainContext];
|
NSManagedObjectContext *moc = [self getMainContext];
|
||||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName:Feed.entity.name];
|
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
|
||||||
fr.predicate = [NSPredicate predicateWithFormat:@"config = NULL"];
|
fr.predicate = [NSPredicate predicateWithFormat:@"group = NULL"];
|
||||||
NSBatchDeleteRequest *bdr = [[NSBatchDeleteRequest alloc] initWithFetchRequest:fr];
|
NSBatchDeleteRequest *bdr = [[NSBatchDeleteRequest alloc] initWithFetchRequest:fr];
|
||||||
NSError *err;
|
NSError *err;
|
||||||
[moc executeRequest:bdr error:&err];
|
[moc executeRequest:bdr error:&err];
|
||||||
if (err) NSLog(@"%@", err);
|
if (err) NSLog(@"%@", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Iterate over all @c Feed and re-calculate @c unreadCount, @c articleCount and @c indexPath.
|
||||||
|
*/
|
||||||
+ (void)restoreFeedCountsAndIndexPaths {
|
+ (void)restoreFeedCountsAndIndexPaths {
|
||||||
NSManagedObjectContext *moc = [self getMainContext];
|
NSManagedObjectContext *moc = [self getMainContext];
|
||||||
NSError *err;
|
NSError *err;
|
||||||
@@ -161,16 +183,13 @@
|
|||||||
if (err) NSLog(@"%@", err);
|
if (err) NSLog(@"%@", err);
|
||||||
[moc performBlock:^{
|
[moc performBlock:^{
|
||||||
for (Feed *feed in result) {
|
for (Feed *feed in result) {
|
||||||
int16_t totalCount = (int16_t)feed.items.count;
|
int16_t totalCount = (int16_t)feed.articles.count;
|
||||||
int16_t unreadCount = (int16_t)[[feed.items valueForKeyPath:@"@sum.unread"] integerValue];
|
int16_t unreadCount = (int16_t)[[feed.articles valueForKeyPath:@"@sum.unread"] integerValue];
|
||||||
if (feed.articleCount != totalCount)
|
if (feed.articleCount != totalCount)
|
||||||
feed.articleCount = totalCount;
|
feed.articleCount = totalCount;
|
||||||
if (feed.unreadCount != unreadCount)
|
if (feed.unreadCount != unreadCount)
|
||||||
feed.unreadCount = unreadCount; // remember to update global total unread count
|
feed.unreadCount = unreadCount; // remember to update global total unread count
|
||||||
|
[feed calculateAndSetIndexPathString];
|
||||||
NSString *pathStr = [feed.config indexPathString];
|
|
||||||
if (![feed.indexPath isEqualToString:pathStr])
|
|
||||||
feed.indexPath = pathStr;
|
|
||||||
}
|
}
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user