Refactoring Part 3: Feed configuration and CoreData Model

This commit is contained in:
relikd
2018-12-09 01:49:26 +01:00
parent ae4700faca
commit 4c1ec7c474
28 changed files with 751 additions and 520 deletions

View File

@@ -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)

View File

@@ -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 */,

View File

@@ -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

View File

@@ -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);

View File

@@ -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

View File

@@ -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];
} }
} }

View 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

View 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

View File

@@ -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>

View File

@@ -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

View File

@@ -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;

View File

@@ -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

View File

@@ -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];

View File

@@ -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;

View File

@@ -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"/>

View File

@@ -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");

View File

@@ -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"];
} }

View File

@@ -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

View File

@@ -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;

View File

@@ -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

View File

@@ -24,5 +24,5 @@
@interface BarMenu : NSObject <NSMenuDelegate> @interface BarMenu : NSObject <NSMenuDelegate>
- (void)updateBarIcon; - (void)updateBarIcon;
- (void)reloadUnreadCountAndUpdateBarIcon; - (void)asyncReloadUnreadCountAndUpdateBarIcon;
@end @end

View File

@@ -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];

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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;
} }
} }

View File

@@ -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;

View File

@@ -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;
} }
}]; }];
} }