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] Pause on internet connection lost
- [ ] Download with ephemeral url session?
- [ ] Purge cache
- [ ] Manually or automatically
- [ ] Add something to restore a broken state
- [ ] Code Documentation (mostly methods)
- [x] Purge cache
- [x] Manually or automatically
- [x] Add something to restore a broken state
- [x] Code Documentation (mostly methods)
- [ ] Add Sandboxing
- [ ] Disable Startup checkbox (or other workaround)

View File

@@ -7,6 +7,7 @@
objects = {
/* 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 */; };
54195886218E1BDB00581B79 /* NSMenu+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54195885218E1BDB00581B79 /* NSMenu+Ext.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 */; };
546FC44421189975007CC3A3 /* SettingsGeneral.xib in Resources */ = {isa = PBXBuildFile; fileRef = 546FC44221189975007CC3A3 /* SettingsGeneral.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 */; };
54ACC28621061B3C0020715F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 54ACC28521061B3C0020715F /* Assets.xcassets */; };
54ACC28C21061B3C0020715F /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC28B21061B3C0020715F /* main.m */; };
@@ -72,6 +73,8 @@
/* End PBXCopyFilesBuildPhase 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>"; };
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>"; };
@@ -94,8 +97,8 @@
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>"; };
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>"; };
5477D34D21233C62002BA27F /* FeedConfig+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "FeedConfig+Ext.m"; sourceTree = "<group>"; };
5477D34C21233C62002BA27F /* FeedGroup+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FeedGroup+Ext.h"; 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>"; };
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; };
@@ -143,10 +146,12 @@
54195880218A05E700581B79 /* Categories */ = {
isa = PBXGroup;
children = (
5477D34C21233C62002BA27F /* FeedConfig+Ext.h */,
5477D34D21233C62002BA27F /* FeedConfig+Ext.m */,
5477D34C21233C62002BA27F /* FeedGroup+Ext.h */,
5477D34D21233C62002BA27F /* FeedGroup+Ext.m */,
54195881218A061100581B79 /* Feed+Ext.h */,
54195882218A061100581B79 /* Feed+Ext.m */,
540F704321B6C16C0022E69D /* FeedMeta+Ext.h */,
540F704421B6C16C0022E69D /* FeedMeta+Ext.m */,
);
path = Categories;
sourceTree = "<group>";
@@ -386,7 +391,8 @@
544B011D2114EE9100386E5C /* AppHook.m in Sources */,
546FC44321189975007CC3A3 /* SettingsGeneral.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 */,
54FE73D3212316CD003EAC65 /* BarMenu.m in Sources */,
544B011A2114B41200386E5C /* ModalSheet.m in Sources */,

View File

@@ -25,9 +25,12 @@
@class RSParsedFeed;
@interface Feed (Ext)
- (void)updateWithRSS:(RSParsedFeed*)obj;
- (NSArray<FeedItem*>*)sortedArticles;
// Generator methods / Feed update
+ (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context;
- (void)calculateAndSetIndexPathString;
- (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag;
// Article properties
- (NSArray<FeedArticle*>*)sortedArticles;
- (int)markAllItemsRead;
- (int)markAllItemsUnread;
@end

View File

@@ -21,23 +21,48 @@
// SOFTWARE.
#import "Feed+Ext.h"
#import "FeedConfig+Ext.h"
#import "FeedItem+CoreDataClass.h"
#import "FeedMeta+Ext.h"
#import "FeedGroup+Ext.h"
#import "FeedArticle+CoreDataClass.h"
#import "Constants.h"
#import <RSXML/RSXML.h>
@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.
*/
- (void)updateWithRSS:(RSParsedFeed*)obj {
- (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag {
if (![self.title isEqualToString:obj.title]) self.title = obj.title;
if (![self.subtitle isEqualToString:obj.subtitle]) self.subtitle = obj.subtitle;
if (![self.link isEqualToString:obj.link]) self.link = obj.link;
NSMutableSet<NSString*> *urls = [[self.items valueForKeyPath:@"link"] mutableCopy];
if ([self addMissingArticles:obj updateLinks:urls]) // will remove links in 'urls' that should be kept
int32_t unreadBefore = self.unreadCount;
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
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.
*/
- (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;
[obj.articles enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(RSParsedArticle * _Nonnull article, BOOL * _Nonnull stop) {
// reverse enumeration ensures correct article order
@@ -68,17 +93,17 @@
Create article based on input and insert into core data storage.
*/
- (void)insertArticle:(RSParsedArticle*)entry atIndex:(int)idx {
FeedItem *b = [[FeedItem alloc] initWithEntity:FeedItem.entity insertIntoManagedObjectContext:self.managedObjectContext];
b.sortIndex = (int32_t)idx;
b.unread = YES;
b.guid = entry.guid;
b.title = entry.title;
b.abstract = entry.abstract;
b.body = entry.body;
b.author = entry.author;
b.link = entry.link;
b.published = entry.datePublished;
[self addItemsObject:b];
FeedArticle *fa = [[FeedArticle alloc] initWithEntity:FeedArticle.entity insertIntoManagedObjectContext:self.managedObjectContext];
fa.sortIndex = (int32_t)idx;
fa.unread = YES;
fa.guid = entry.guid;
fa.title = entry.title;
fa.abstract = entry.abstract;
fa.body = entry.body;
fa.author = entry.author;
fa.link = entry.link;
fa.published = entry.datePublished;
[self addArticlesObject:fa];
}
/**
@@ -87,28 +112,29 @@
- (void)deleteArticlesWithLink:(NSMutableSet<NSString*>*)urls {
if (!urls || urls.count == 0)
return;
self.articleCount -= (int32_t)urls.count;
for (FeedItem *item in self.items) {
if ([urls containsObject:item.link]) {
[urls removeObject:item.link];
if (item.unread)
for (FeedArticle *fa in self.articles) {
if ([urls containsObject:fa.link]) {
[urls removeObject:fa.link];
if (fa.unread)
self.unreadCount -= 1;
// TODO: keep unread articles?
[item.managedObjectContext deleteObject:item];
[fa.managedObjectContext deleteObject:fa];
if (urls.count == 0)
break;
}
}
}
#pragma mark - Article Properties -
/**
@return Articles sorted by attribute @c sortIndex with descending order (newest items first).
*/
- (NSArray<FeedItem*>*)sortedArticles {
if (self.items.count == 0)
- (NSArray<FeedArticle*>*)sortedArticles {
if (self.articles.count == 0)
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
*/
- (int)markAllArticlesRead:(BOOL)readFlag {
for (FeedItem *i in self.items) {
if (i.unread == readFlag)
i.unread = !readFlag;
for (FeedArticle *fa in self.articles) {
if (fa.unread == readFlag)
fa.unread = !readFlag;
}
int32_t oldCount = self.unreadCount;
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
// SOFTWARE.
#import "FeedConfig+CoreDataClass.h"
#import "FeedGroup+CoreDataClass.h"
@class FeedItem, RSParsedFeed;
@interface FeedConfig (Ext)
/// Enum type to distinguish different @c FeedConfig types
@interface FeedGroup (Ext)
/// Enum type to distinguish different @c FeedGroup types: @c GROUP, @c FEED, @c SEPARATOR
typedef enum int16_t {
/// Other types: @c GROUP, @c FEED, @c SEPARATOR
GROUP = 0,
FEED = 1,
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
- (NSString*)indexPathString;
- (NSMutableArray<FeedConfig*>*)allParents;
- (NSMutableArray<FeedGroup*>*)allParents;
- (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
- (NSString*)readableRefreshString;
- (NSString*)readableDescription;
@end

View File

@@ -20,16 +20,31 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import "FeedConfig+Ext.h"
#import "FeedGroup+Ext.h"
#import "FeedMeta+Ext.h"
#import "Feed+Ext.h"
#import "FeedMeta+CoreDataClass.h"
#import "Constants.h"
@implementation FeedConfig (Ext)
/// Enum tpye getter see @c FeedConfigType
- (FeedConfigType)typ { return (FeedConfigType)self.type; }
/// Enum type setter see @c FeedConfigType
- (void)setTyp:(FeedConfigType)typ { self.type = typ; }
@implementation FeedGroup (Ext)
/// Enum tpye getter see @c FeedGroupType
- (FeedGroupType)typ { return (FeedGroupType)self.type; }
/// Enum type setter see @c FeedGroupType
- (void)setTyp:(FeedGroupType)typ { self.type = typ; }
/// Create new instance and set @c Feed and @c FeedMeta if group type is @c FEED
+ (instancetype)newGroup:(FeedGroupType)type inContext:(NSManagedObjectContext*)moc {
FeedGroup *fg = [[FeedGroup alloc] initWithEntity: FeedGroup.entity insertIntoManagedObjectContext:moc];
fg.typ = type;
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 -
@@ -43,14 +58,14 @@
}
/// @return Children sorted by attribute @c sortIndex (same order as in preferences).
- (NSArray<FeedConfig*>*)sortedChildren {
- (NSArray<FeedGroup*>*)sortedChildren {
if (self.children.count == 0)
return nil;
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.
- (NSMutableArray<FeedConfig*>*)allParents {
/// @return @c NSArray of all ancestors: First object is root. Last object is the @c FeedGroup that executed the command.
- (NSMutableArray<FeedGroup*>*)allParents {
if (self.parent == nil)
return [NSMutableArray arrayWithObject:self];
NSMutableArray *arr = [self.parent allParents];
@@ -71,8 +86,8 @@
block(self.feed, &stopEarly);
if (stopEarly) return NO;
} else {
for (FeedConfig *fc in (ordered ? [self sortedChildren] : self.children)) {
if (![fc iterateSorted:ordered overDescendantFeeds:block])
for (FeedGroup *fg in (ordered ? [self sortedChildren] : self.children)) {
if (![fg iterateSorted:ordered overDescendantFeeds:block])
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 -
/// @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.
- (NSString*)readableDescription {
switch (self.typ) {
case SEPARATOR: return @"-------------";
case GROUP: return [NSString stringWithFormat:@"%@", self.name];
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="title" optional="YES" attributeType="String" 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="items" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="FeedItem" inverseName="feed" inverseEntity="FeedItem" syncable="YES"/>
<relationship name="articles" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="FeedArticle" inverseName="feed" inverseEntity="FeedArticle" 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 name="FeedConfig" representedClassName="FeedConfig" 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">
<entity name="FeedArticle" representedClassName="FeedArticle" syncable="YES" codeGenerationType="class">
<attribute name="abstract" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="author" 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="title" optional="YES" attributeType="String" 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 name="FeedMeta" representedClassName="FeedMeta" syncable="YES" codeGenerationType="class">
<attribute name="httpEtag" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="httpModified" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="icon" optional="YES" attributeType="Binary" customClassName="NSImage" syncable="YES"/>
<relationship name="config" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="FeedConfig" inverseName="meta" inverseEntity="FeedConfig" syncable="YES"/>
<attribute name="errorCount" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<attribute name="etag" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="modified" 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="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>
<elements>
<element name="Feed" positionX="-229.09375" positionY="-2.30859375" width="128" height="165"/>
<element name="FeedConfig" positionX="-441.87109375" positionY="-47.390625" width="128" height="225"/>
<element name="FeedItem" positionX="-28.140625" positionY="-17.359375" width="128" height="195"/>
<element name="FeedMeta" positionX="-234" positionY="72" width="128" height="105"/>
<element name="Feed" positionX="-278.84765625" positionY="-112.953125" width="128" height="195"/>
<element name="FeedGroup" positionX="-460.37890625" positionY="-111.62890625" width="130.52734375" height="150"/>
<element name="FeedArticle" positionX="-96.77734375" positionY="-113.83984375" width="128" height="195"/>
<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>
</model>

View File

@@ -28,5 +28,5 @@
+ (void)registerNetworkChangeNotification;
+ (void)unregisterNetworkChangeNotification;
+ (BOOL)isNetworkReachable;
+ (void)scheduleNextUpdate:(BOOL)forceUpdate;
+ (void)scheduleNextUpdateForced:(BOOL)flag;
@end

View File

@@ -23,6 +23,9 @@
#import "FeedDownload.h"
#import "Constants.h"
#import "StoreCoordinator.h"
#import "Feed+Ext.h"
#import "FeedMeta+Ext.h"
#import <SystemConfiguration/SystemConfiguration.h>
static SCNetworkReachabilityRef _reachability = NULL;
@@ -31,6 +34,7 @@ static BOOL _isReachable = NO;
@implementation FeedDownload
/// @return New request with no caching policy and timeout interval of 30 seconds.
+ (NSMutableURLRequest*)newRequestURL:(NSString*)url {
NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]];
req.timeoutInterval = 30;
@@ -40,16 +44,20 @@ static BOOL _isReachable = NO;
return req;
}
+ (NSURLRequest*)newRequest:(FeedConfig*)config {
NSMutableURLRequest *req = [self newRequestURL:config.url];
NSString* etag = [config.meta.httpEtag stringByReplacingOccurrencesOfString:@"-gzip" withString:@""];
if (config.meta.httpModified.length > 0)
[req setValue:config.meta.httpModified forHTTPHeaderField:@"If-Modified-Since"];
/// @return New request with etag and modified headers set.
+ (NSURLRequest*)newRequest:(FeedMeta*)meta {
NSMutableURLRequest *req = [self newRequestURL:meta.url];
NSString* etag = [meta.etag stringByReplacingOccurrencesOfString:@"-gzip" withString:@""];
if (meta.modified.length > 0)
[req setValue:meta.modified forHTTPHeaderField:@"If-Modified-Since"];
if (etag.length > 0)
[req setValue:etag forHTTPHeaderField:@"If-None-Match"]; // ETag
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 {
[[[NSURLSession sharedSession] dataTaskWithRequest:[self newRequestURL:url] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
NSHTTPURLResponse* httpResponse = (NSHTTPURLResponse*)response;
@@ -59,6 +67,10 @@ static BOOL _isReachable = NO;
}
RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:url];
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);
});
}] resume];
@@ -68,7 +80,12 @@ static BOOL _isReachable = NO;
#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;
@synchronized (_updateTimer) { // TODO: dig into analyzer warning
if (_updateTimer) {
@@ -80,7 +97,8 @@ static BOOL _isReachable = NO;
NSDate *nextTime = [NSDate dateWithTimeIntervalSinceNow:0.2];
if (!forceUpdate) {
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?
}
}
@@ -91,82 +109,97 @@ static BOOL _isReachable = NO;
[[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 {
NSLog(@"fired");
BOOL forceAll = [timer.userInfo boolValue];
// TODO: check internet connection
// TODO: disable menu item 'update all' during update
__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) {
NSLog(@"ERROR: Something went wrong, timer fired too early.");
[childContext reset];
childContext = nil;
// thechnically should never happen, anyway we need to reset the timer
[self 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
}
dispatch_group_t group = dispatch_group_create();
for (FeedConfig *c in list) {
[self downloadFeedForConfig:c group:group];
for (Feed *feed in list) {
[self downloadFeed:feed group:group];
}
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
[StoreCoordinator saveContext:childContext andParent:YES];
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:[list valueForKeyPath:@"objectID"]];
[childContext reset];
childContext = nil;
[self 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;
dispatch_group_enter(group);
[[[NSURLSession sharedSession] dataTaskWithRequest:[self newRequest:config] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
[config.managedObjectContext performBlock:^{
// core data block inside of url session block; otherwise config access will EXC_BAD_INSTRUCTION
[[[NSURLSession sharedSession] dataTaskWithRequest:[self newRequest:feed.meta] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
[feed.managedObjectContext performBlock:^{
// core data block inside of url session block; otherwise access will EXC_BAD_INSTRUCTION
if (error) {
int16_t n = config.errorCount + 1;
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];
[feed.meta setErrorAndPostponeSchedule];
// TODO: remove logging
NSLog(@"Error loading: %@ (%d)", response.URL, config.errorCount);
NSLog(@"Error loading: %@ (%d)", response.URL, feed.meta.errorCount);
} else {
config.errorCount = 0; // reset counter
[self downloadSuccessful:data forFeed:config response:(NSHTTPURLResponse*)response];
feed.meta.errorCount = 0; // reset counter
[self downloadSuccessful:data forFeed:feed response:(NSHTTPURLResponse*)response];
}
dispatch_group_leave(group);
}];
}] 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) {
// 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);
if (parsed) {
// TODO: add support for media player?
// <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
[config calculateAndSetScheduled];
// [config mergeChangesAndSave];
// [config.managedObjectContext performBlock:^{
// [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:config.objectID];
// }];
[feed.meta calculateAndSetScheduled];
// TODO: save changes for this feed only?
// [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:feed.objectID];
}
#pragma mark - Network Connection -
/// External getter to check wheter current network state is reachable.
+ (BOOL)isNetworkReachable { return _isReachable; }
/// Set callback on @c self to listen for network reachability changes.
+ (void)registerNetworkChangeNotification {
// https://stackoverflow.com/questions/11240196/notification-when-wifi-connected-os-x
if (_reachability != NULL) return;
@@ -184,6 +217,7 @@ static BOOL _isReachable = NO;
}
}
/// Remove @c self callback (network reachability changes).
+ (void)unregisterNetworkChangeNotification {
if (_reachability != NULL) {
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) {
if (_reachability == NULL)
return;
@@ -205,9 +240,10 @@ static void networkReachabilityCallback(SCNetworkReachabilityRef target, SCNetwo
NSLog(@"not reachable");
}
// 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 {
if ((flags & kSCNetworkReachabilityFlagsReachable) == 0)
return NO;

View File

@@ -21,22 +21,20 @@
// SOFTWARE.
#import <Cocoa/Cocoa.h>
#import "ModalSheet.h"
@class FeedConfig;
@class FeedGroup;
@protocol ModalEditDelegate <NSObject>
- (void)modalDidUpdateFeedConfig:(FeedConfig*)config;
@end
@protocol ModalFeedConfigEdit <NSObject>
@property (weak) id<ModalEditDelegate> delegate;
- (void)updateRepresentedObject; // must call [item.managedObjectContext refreshObject:item mergeChanges:YES];
@interface ModalEditDialog : NSViewController
+ (instancetype)modalWith:(FeedGroup*)group;
- (ModalSheet*)getModalSheet;
- (void)applyChangesToCoreDataObject;
@end
@interface ModalFeedEdit : NSViewController <ModalFeedConfigEdit, NSTextFieldDelegate>
@interface ModalFeedEdit : ModalEditDialog <NSTextFieldDelegate>
@end
@interface ModalGroupEdit : NSViewController <ModalFeedConfigEdit>
@interface ModalGroupEdit : ModalEditDialog
@end

View File

@@ -23,6 +23,42 @@
#import "ModalFeedEdit.h"
#import "FeedDownload.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()
@property (weak) IBOutlet NSTextField *url;
@@ -34,143 +70,135 @@
@property (weak) IBOutlet NSButton *warningIndicator;
@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 *httpEtag;
@property (strong) NSError *feedError;
@property (strong) RSParsedFeed *feedResult;
@property (assign) BOOL shouldSaveObject;
@property (assign) BOOL shouldDeletePrevArticles;
@property (assign) BOOL objectNeedsSaving;
@property (assign) BOOL objectIsModified;
@property (strong) NSError *feedError; // download error or xml parser error
@property (strong) RSParsedFeed *feedResult; // parsed result
@property (assign) BOOL didDownloadFeed; // check if feed articles need update
@end
@implementation ModalFeedEdit
@synthesize delegate;
/// Init feed edit dialog with default values.
- (void)viewDidLoad {
[super viewDidLoad];
self.previousURL = @"";
self.refreshNum.intValue = 30;
self.shouldSaveObject = NO;
self.shouldDeletePrevArticles = NO;
self.objectNeedsSaving = NO;
self.objectIsModified = NO;
[self populateTextFields:self.feedGroup];
}
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.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];
}
self.previousURL = self.url.stringValue;
#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) {
if (self.objectNeedsSaving)
[self updateRepresentedObject];
FeedConfig *item = [self feedConfigOrNil];
NSUndoManager *um = item.managedObjectContext.undoManager;
[um endUndoGrouping];
if (!self.objectIsModified) {
[um disableUndoRegistration];
[um undoNestedGroup];
[um enableUndoRegistration];
} else {
[self.delegate modalDidUpdateFeedConfig:item];
}
/**
Prepare UI (nullify @c result, @c error and start @c ProgressIndicator) and perform HTTP request.
Articles will be parsed and stored in class variables.
This should avoid unnecessary core data operations if user decides to cancel the edit.
The save operation will only be executed if user clicks on the 'OK' button.
*/
- (void)downloadRSS {
[self.modalSheet setDoneEnabled:NO];
// Assuming the user has not changed title since the last fetch.
// Reset to "" because after download it will be pre-filled with new feed title
if ([self.name.stringValue isEqualToString:self.feedResult.title]) {
self.name.stringValue = @"";
}
self.feedResult = nil;
self.feedError = nil;
self.httpEtag = nil;
self.httpDate = nil;
self.didDownloadFeed = NO;
[self.spinnerURL startAnimation:nil];
[self.spinnerName startAnimation:nil];
[FeedDownload newFeed:self.previousURL block:^(RSParsedFeed *result, NSError *error, NSHTTPURLResponse* response) {
dispatch_async(dispatch_get_main_queue(), ^{
if (self.modalSheet.closeInitiated)
return;
self.didDownloadFeed = YES;
self.feedResult = result;
self.feedError = error; // MAIN THREAD!: warning indicator .hidden is bound to feedError
self.httpEtag = [response allHeaderFields][@"Etag"];
self.httpDate = [response allHeaderFields][@"Date"]; // @"Expires", @"Last-Modified"
[self updateTextFieldURL:response.URL.absoluteString andTitle:result.title];
// TODO: 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;
}
// Copy feed title to text field. (only if user hasn't set anything else yet)
if ([self.name.stringValue isEqualToString:@""] && feedTitle.length > 0) {
self.name.stringValue = feedTitle; // no damage to replace an empty string
}
}
- (void)updateRepresentedObject {
FeedConfig *item = [self feedConfigOrNil];
if (!item)
return;
if (!self.shouldSaveObject) // first call to this method
[item.managedObjectContext.undoManager beginUndoGrouping];
self.shouldSaveObject = YES;
self.objectNeedsSaving = NO; // after this method it is saved
// if's to prevent unnecessary undo groups if nothing has changed
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
}
if ([item.managedObjectContext hasChanges]) {
self.objectIsModified = YES;
[item calculateAndSetScheduled];
[item.managedObjectContext refreshObject:item mergeChanges:YES];
}
}
- (FeedConfig*)feedConfigOrNil {
if ([self.representedObject isKindOfClass:[FeedConfig class]])
return self.representedObject;
return nil;
}
#pragma mark - NSTextField Delegate
/// Helper method to check whether url was modified since last download.
- (BOOL)urlHasChanged {
return ![self.previousURL isEqualToString:self.url.stringValue];
}
/// Hide warning button if an error was present but the user changed the url since.
- (void)controlTextDidChange:(NSNotification *)obj {
if (obj.object == self.url) {
self.warningIndicator.hidden = (!self.feedError || [self urlHasChanged]);
}
}
/// Whenever the user finished entering the url (return key or focus change) perform a download request.
- (void)controlTextDidEndEditing:(NSNotification *)obj {
if (obj.object == self.url && [self urlHasChanged]) {
self.shouldDeletePrevArticles = YES;
if (self.modalSheet.closeInitiated)
return;
self.previousURL = self.url.stringValue;
self.feedResult = nil;
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;
[self downloadRSS];
}
}
/// Warning button next to url text field. Will be visible if an error occurs during download.
- (IBAction)didClickWarningButton:(NSButton*)sender {
if (!self.feedError)
return;
@@ -191,51 +219,49 @@
@end
#pragma mark - ModalGroupEdit
#pragma mark - ModalGroupEdit -
@implementation ModalGroupEdit
@synthesize delegate;
/// Init view and set group name if edeting an already existing object.
- (void)viewDidLoad {
[super viewDidLoad];
if ([self.representedObject isKindOfClass:[FeedConfig class]]) {
FeedConfig *fc = self.representedObject;
((NSTextField*)self.view).objectValue = fc.name;
}
if (self.feedGroup && ![self.feedGroup hasChanges]) // hasChanges is true only if newly created
((NSTextField*)self.view).objectValue = self.feedGroup.name;
}
/// Set one single @c NSTextField as entire view. Populate with default value and placeholder.
- (void)loadView {
NSTextField *tf = [NSTextField textFieldWithString:NSLocalizedString(@"New Group", nil)];
tf.placeholderString = NSLocalizedString(@"New Group", nil);
tf.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin;
self.view = tf;
}
- (void)updateRepresentedObject {
if ([self.representedObject isKindOfClass:[FeedConfig class]]) {
FeedConfig *item = self.representedObject;
NSString *name = ((NSTextField*)self.view).stringValue;
if (![item.name isEqualToString: name]) {
item.name = name;
[item.managedObjectContext refreshObject:item mergeChanges:YES];
[self.delegate modalDidUpdateFeedConfig:item];
}
}
/// Edit of group finished. Save changes to core data object and perform save operation on delegate.
- (void)applyChangesToCoreDataObject {
NSString *name = ((NSTextField*)self.view).stringValue;
if (![self.feedGroup.name isEqualToString:name])
self.feedGroup.name = name;
}
@end
#pragma mark - StrictUIntFormatter
#pragma mark - StrictUIntFormatter -
@interface StrictUIntFormatter : NSFormatter
@end
@implementation StrictUIntFormatter
/// Display object as integer formatted string.
- (NSString *)stringForObjectValue:(id)obj {
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 {
*obj = [[NSNumber numberWithInt:[string intValue]] stringValue];
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 {
for (NSUInteger i = 0; i < [*partialStringPtr length]; i++) {
unichar c = [*partialStringPtr characterAtIndex:i];

View File

@@ -21,18 +21,17 @@
// SOFTWARE.
#import "SettingsFeeds.h"
#import "BarMenu.h"
#import "ModalSheet.h"
#import "ModalFeedEdit.h"
#import "Constants.h"
#import "DrawImage.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 NSTreeController *dataStore;
@property (strong) NSViewController<ModalFeedConfigEdit> *modalController;
@property (strong) NSArray<NSTreeNode*> *currentlyDraggedNodes;
@property (strong) NSUndoManager *undoManager;
@end
@@ -60,22 +59,21 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
}
- (IBAction)addFeed:(id)sender {
[self showModalForFeedConfig:nil isGroupEdit:NO];
[self showModalForFeedGroup:nil isGroupEdit:NO];
}
- (IBAction)addGroup:(id)sender {
[self showModalForFeedConfig:nil isGroupEdit:YES];
[self showModalForFeedGroup:nil isGroupEdit:YES];
}
- (IBAction)addSeparator:(id)sender {
[self.undoManager beginUndoGrouping];
FeedConfig *sp = [self insertSortedItemAtSelection];
sp.name = @"---";
sp.typ = SEPARATOR;
[self insertFeedGroupAtSelection:SEPARATOR].name = @"---";
[self.undoManager endUndoGrouping];
[self saveChanges];
}
/// Remove user selected item from persistent store.
- (IBAction)remove:(id)sender {
[self.undoManager beginUndoGrouping];
NSArray<NSTreeNode*> *parentNodes = [self.dataStore.selectedNodes valueForKeyPath:@"parentNode"];
@@ -88,99 +86,104 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
}
/// Open user selected item for editing.
- (IBAction)doubleClickOutlineView:(NSOutlineView*)sender {
if (sender.clickedRow == -1)
return; // ignore clicks on column headers and where no row was selected
FeedConfig *fc = [(NSTreeNode*)[sender itemAtRow:sender.clickedRow] representedObject];
[self showModalForFeedConfig:fc isGroupEdit:YES]; // yes will be overwritten anyway
FeedGroup *fg = [(NSTreeNode*)[sender itemAtRow:sender.clickedRow] representedObject];
[self showModalForFeedGroup:fg isGroupEdit:YES]; // yes will be overwritten anyway
}
#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 {
BOOL existingItem = [obj isKindOfClass:[FeedConfig class]];
if (existingItem) {
if (obj.typ == SEPARATOR) return;
group = (obj.typ == GROUP);
@param fg @c FeedGroup to be edited. If @c nil a new object will be created at the current selection.
@param flag If @c YES open group edit modal dialog. If @c NO open feed edit modal dialog.
*/
- (void)showModalForFeedGroup:(FeedGroup*)fg isGroupEdit:(BOOL)flag {
if (fg.typ == SEPARATOR) return;
[self.undoManager beginUndoGrouping];
if (!fg || ![fg isKindOfClass:[FeedGroup class]]) {
fg = [self insertFeedGroupAtSelection:(flag ? GROUP : FEED)];
}
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) {
ModalEditDialog *editDialog = (fg.typ == GROUP ? [ModalGroupEdit modalWith:fg] : [ModalFeedEdit modalWith:fg]);
[self.view.window beginSheet:[editDialog getModalSheet] completionHandler:^(NSModalResponse returnCode) {
if (returnCode == NSModalResponseOK) {
if (!existingItem) { // create new item
[self.undoManager beginUndoGrouping];
FeedConfig *item = [self insertSortedItemAtSelection];
item.typ = (group ? GROUP : FEED);
self.modalController.representedObject = item;
}
[self.modalController updateRepresentedObject];
if (!existingItem)
[self.undoManager endUndoGrouping];
[editDialog applyChangesToCoreDataObject];
[self.undoManager endUndoGrouping];
} else {
[self.undoManager endUndoGrouping];
[self.dataStore.managedObjectContext rollback];
}
BOOL hasChanges = [self.dataStore.managedObjectContext hasChanges];
if (hasChanges) {
[self saveChanges];
[self.dataStore rearrangeObjects];
} else {
[self.undoManager disableUndoRegistration];
[self.undoManager undoNestedGroup];
[self.undoManager enableUndoRegistration];
}
self.modalController = nil;
}];
}
/// Called after an item was modified. May be called twice if download was still in progress.
- (void)modalDidUpdateFeedConfig:(FeedConfig*)config {
[self saveChanges];
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
}
#pragma mark - Helper -
/// Insert @c FeedConfig item either after current selection or inside selected folder (if expanded)
- (FeedConfig*)insertSortedItemAtSelection {
FeedConfig *newItem = [[FeedConfig alloc] initWithEntity:FeedConfig.entity insertIntoManagedObjectContext:self.dataStore.managedObjectContext];
NSTreeNode *selection = [[self.dataStore selectedNodes] firstObject];
NSIndexPath *pth = nil;
/// Insert @c FeedGroup item either after current selection or inside selected folder (if expanded)
- (FeedGroup*)insertFeedGroupAtSelection:(FeedGroupType)type {
FeedGroup *fg = [FeedGroup newGroup:type inContext:self.dataStore.managedObjectContext];
NSIndexPath *pth = [self indexPathForInsertAtNode:[[self.dataStore selectedNodes] firstObject]];
[self.dataStore insertObject:fg atArrangedObjectIndexPath:pth];
if (!selection) { // append to root
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!)
if (pth.length > 1) { // some subfolder and not root folder (has parent!)
NSTreeNode *parentNode = [[self.dataStore arrangedObjects] descendantNodeAtIndexPath:pth].parentNode;
newItem.parent = parentNode.representedObject;
fg.parent = parentNode.representedObject;
[self restoreOrderingAndIndexPathStr:parentNode];
} else {
[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 {
NSArray<NSTreeNode*> *children = parent.childNodes;
for (NSUInteger i = 0; i < children.count; i++) {
NSTreeNode *n = [children objectAtIndex:i];
FeedConfig *fc = n.representedObject;
// Re-calculate sort index for all affected parents
if (fc.sortIndex != (int32_t)i)
fc.sortIndex = (int32_t)i;
// Re-calculate index path for all contained feed items
[fc iterateSorted:NO overDescendantFeeds:^(Feed *feed, BOOL *cancel) {
NSString *pthStr = [feed.config indexPathString];
if (![feed.indexPath isEqualToString:pthStr])
feed.indexPath = pthStr;
FeedGroup *fg = [children objectAtIndex:i].representedObject;
if (fg.sortIndex != (int32_t)i)
fg.sortIndex = (int32_t)i;
NSLog(@"%@ - %d", fg.name, fg.sortIndex);
[fg iterateSorted:NO overDescendantFeeds:^(Feed *feed, BOOL *cancel) {
[feed calculateAndSetIndexPathString];
}];
}
}
@@ -189,6 +192,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
#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 {
[self.undoManager beginUndoGrouping];
[pboard declareTypes:[NSArray arrayWithObject:dragNodeType] owner:self];
@@ -197,6 +201,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
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 {
[self.undoManager endUndoGrouping];
if (self.dataStore.managedObjectContext.hasChanges) {
@@ -209,6 +214,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
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 {
NSTreeNode *destParent = (item != nil ? item : [self.dataStore arrangedObjects]);
NSUInteger idx = (NSUInteger)index;
@@ -227,13 +233,12 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
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 {
FeedConfig *fc = [(NSTreeNode*)item representedObject];
if (index == -1 && fc.typ != GROUP) { // if drag is on specific item and that item isnt a group
NSTreeNode *parent = item;
if (index == -1 && [parent isLeaf]) { // if drag is on specific item and that item isnt a group
return NSDragOperationNone;
}
NSTreeNode *parent = item;
while (parent != nil) {
for (NSTreeNode *node in self.currentlyDraggedNodes) {
if (parent == node)
@@ -248,10 +253,11 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
#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 {
FeedConfig *f = [(NSTreeNode*)item representedObject];
BOOL isFeed = (f.typ == FEED);
BOOL isSeperator = (f.typ == SEPARATOR);
FeedGroup *fg = [(NSTreeNode*)item representedObject];
BOOL isFeed = (fg.typ == FEED);
BOOL isSeperator = (fg.typ == SEPARATOR);
BOOL isRefreshColumn = [tableColumn.identifier isEqualToString:@"RefreshColumn"];
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];
if (isRefreshColumn) {
cellView.textField.stringValue = (!isFeed ? @"" : [f readableRefreshString]);
cellView.textField.stringValue = (isFeed && fg.refreshStr.length > 0 ? fg.refreshStr : @"");
} else if (isSeperator) {
return cellView; // the refresh cell is already skipped with the above if condition
} else {
cellView.textField.objectValue = f.name;
if (f.typ == GROUP) {
cellView.textField.objectValue = fg.name;
if (fg.typ == GROUP) {
cellView.imageView.image = [NSImage imageNamed:NSImageNameFolder];
} else {
// TODO: load icon
@@ -275,8 +281,10 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
cellView.imageView.image = defaultRSSIcon;
}
}
if (isFeed) // also for refresh column
cellView.textField.textColor = (f.refreshNum == 0 ? [NSColor disabledControlTextColor] : [NSColor controlTextColor]);
if (isFeed) {// also for refresh column
BOOL feedDisbaled = (fg.refreshStr.length == 0 || [fg.refreshStr characterAtIndex:0] == '0');
cellView.textField.textColor = (feedDisbaled ? [NSColor disabledControlTextColor] : [NSColor controlTextColor]);
}
return cellView;
}
@@ -284,6 +292,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
#pragma mark - Keyboard Commands: undo, redo, copy, enter
/// Returning @c NO will result in a Action-Not-Available-Buzzer sound
- (BOOL)respondsToSelector:(SEL)aSelector {
if (aSelector == @selector(undo:)) return [self.undoManager canUndo];
if (aSelector == @selector(redo:)) return [self.undoManager canRedo];
@@ -295,11 +304,12 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
if (aSelector == @selector(copy:))
return YES;
// 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];
}
/// Perform undo operation and redraw UI & menu bar unread count
- (void)undo:(id)sender {
[self.undoManager undo];
[self saveChanges];
@@ -307,6 +317,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
[self.dataStore rearrangeObjects]; // update ordering
}
/// Perform redo operation and redraw UI & menu bar unread count
- (void)redo:(id)sender {
[self.undoManager redo];
[self saveChanges];
@@ -314,10 +325,12 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
[self.dataStore rearrangeObjects]; // update ordering
}
/// User pressed enter; open edit dialog for selected item.
- (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 {
NSMutableString *str = [[NSMutableString alloc] init];
NSUInteger count = self.dataStore.selectedNodes.count;

View File

@@ -15,7 +15,7 @@
</customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<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">
<rect key="frame" x="0.0" y="0.0" width="320" height="327"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>

View File

@@ -25,8 +25,8 @@
#import "BarMenu.h"
#import "UserPrefs.h"
#import "StoreCoordinator.h"
#import <ServiceManagement/ServiceManagement.h>
#import <ServiceManagement/ServiceManagement.h>
@interface SettingsGeneral()
@property (weak) IBOutlet NSPopUpButton *popupHttpApplication;
@@ -48,6 +48,7 @@
#pragma mark - UI interaction with IBAction
/// Run helper application to add thyself to startup items.
- (IBAction)changeStartOnLogin:(NSButton *)sender {
// launchctl list | grep de.relikd
CFStringRef helperIdentifier = CFBridgingRetain(@"de.relikd.baRSS-Helper");

View File

@@ -24,6 +24,7 @@
@implementation UserPrefs
/// @return @c YES if key is not set. Otherwise, return user defaults property from plist.
+ (BOOL)defaultYES:(NSString*)key {
if ([[NSUserDefaults standardUserDefaults] objectForKey:key] == NULL) {
return YES;
@@ -31,14 +32,17 @@
return [[NSUserDefaults standardUserDefaults] boolForKey:key];
}
/// @return @c NO if key is not set. Otherwise, return user defaults property from plist.
+ (BOOL)defaultNO:(NSString*)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 {
return [[NSUserDefaults standardUserDefaults] stringForKey:@"defaultHttpApplication"];
}
/// Store custom browser bundle id to user defaults.
+ (void)setHttpApplication:(NSString*)bundleID {
[[NSUserDefaults standardUserDefaults] setObject:bundleID forKey:@"defaultHttpApplication"];
}

View File

@@ -23,6 +23,8 @@
#import <Cocoa/Cocoa.h>
@interface ModalSheet : NSPanel
@property (readonly) BOOL closeInitiated;
+ (instancetype)modalWithView:(NSView*)content;
- (void)setDoneEnabled:(BOOL)accept;
@end

View File

@@ -27,12 +27,22 @@
@end
@implementation ModalSheet
@synthesize closeInitiated = _closeInitiated;
/// User did click the 'Done' button.
- (void)didTapDoneButton:(id)sender { [self closeWithResponse:NSModalResponseOK]; }
/// User did click the 'Cancel' button.
- (void)didTapCancelButton:(id)sender { [self closeWithResponse:NSModalResponseAbort]; }
/// Manually disable 'Done' button if a task is still running.
- (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 {
_closeInitiated = YES;
// store modal view width and remove subviews to avoid _NSKeyboardFocusClipView issues
// first object is always the view of the modal dialog
CGFloat w = self.contentView.subviews.firstObject.frame.size.width;
@@ -41,6 +51,12 @@
[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 {
static const int padWindow = 20;
static const int padButtons = 12;

View File

@@ -32,6 +32,7 @@
@implementation Preferences
/// Restore tab selection from previous session
- (void)windowDidLoad {
[super windowDidLoad];
NSUInteger idx = (NSUInteger)[[NSUserDefaults standardUserDefaults] integerForKey:@"preferencesTab"];
@@ -40,6 +41,7 @@
[self tabClicked:self.window.toolbar.items[idx]];
}
/// Replace content view according to selected tab
- (IBAction)tabClicked:(NSToolbarItem *)sender {
self.window.contentView = nil;
if ([sender.itemIdentifier isEqualToString:@"tabGeneral"]) {
@@ -59,7 +61,7 @@
@end
/// A window that does not respond to Cmd-C, Cmd-Z, Cmd-Shift-Z and Enter-pressed events.
@interface NonRespondingWindow : NSWindow
@end

View File

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

View File

@@ -27,8 +27,9 @@
#import "Preferences.h"
#import "UserPrefs.h"
#import "NSMenu+Ext.h"
#import "Feed+Ext.h"
#import "Constants.h"
#import "Feed+Ext.h"
#import "FeedGroup+Ext.h"
@interface BarMenu()
@@ -52,15 +53,14 @@
// Unread counter
self.unreadCountTotal = 0;
[self updateBarIcon];
[self reloadUnreadCountAndUpdateBarIcon];
[self asyncReloadUnreadCountAndUpdateBarIcon];
// Register for notifications
[[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(unreadCountChanged:) name:kNotificationTotalUnreadCountChanged object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(reloadUnreadCountAndUpdateBarIcon) name:kNotificationTotalUnreadCountReset object:nil];
[FeedDownload registerNetworkChangeNotification];
[FeedDownload performSelectorInBackground:@selector(scheduleNextUpdate:) withObject:[NSNumber numberWithBool:NO]];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(asyncReloadUnreadCountAndUpdateBarIcon) name:kNotificationTotalUnreadCountReset object:nil];
[FeedDownload registerNetworkChangeNotification]; // will call update scheduler
return self;
}
@@ -72,7 +72,7 @@
#pragma mark - Update Menu Bar 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(), ^{
self.unreadCountTotal = [StoreCoordinator unreadCountForIndexPathString:nil];
[self updateBarIcon];
@@ -130,10 +130,10 @@
// update items only if menu is already open (e.g., during background update)
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
for (NSManagedObjectID *oid in notify.object) {
FeedConfig *fc = [moc objectWithID:oid];
NSMenu *menu = [self fixUnreadCountForSubmenus:fc];
Feed *feed = [moc objectWithID:oid];
NSMenu *menu = [self fixUnreadCountForSubmenus:feed];
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
[moc reset];
@@ -143,15 +143,14 @@
/**
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 FeedItem. Will be @c nil if user hasn't open the menu yet.
@return @c NSMenu containing @c FeedArticle. 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;
for (FeedConfig *conf in [config allParents]) {
NSInteger offset = [menu feedConfigOffset];
NSMenuItem *item = [menu itemAtIndex:offset + conf.sortIndex];
NSInteger unread = [item setTitleAndUnreadCount:conf];
for (FeedGroup *parent in [feed.group allParents]) {
NSInteger offset = [menu feedDataOffset];
NSMenuItem *item = [menu itemAtIndex:offset + parent.sortIndex];
NSInteger unread = [item setTitleAndUnreadCount:parent];
menu = item.submenu;
if (!menu || menu.numberOfItems == 0)
return nil;
@@ -168,17 +167,17 @@
@param feed Corresponding @c Feed to @c NSMenu.
@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 the menu isn't open, re-create it dynamically instead
menu.itemArray.firstObject.parentItem.submenu = [menu cleanInstanceCopy];
} else {
[menu removeAllItems];
[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:@""];
mi.target = self;
[mi setFeedItem:fi];
[mi setFeedArticle:fa];
}
}
}
@@ -219,12 +218,12 @@
/// Lazy populate system bar menus when needed.
- (BOOL)menu:(NSMenu*)menu updateItem:(NSMenuItem*)item atIndex:(NSInteger)index shouldCancel:(BOOL)shouldCancel {
id obj = [self.readContext objectWithID:[self.objectIDsForMenu objectAtIndex:(NSUInteger)index]];
if ([obj isKindOfClass:[FeedConfig class]]) {
[item setFeedConfig:obj];
if ([(FeedConfig*)obj typ] == FEED)
if ([obj isKindOfClass:[FeedGroup class]]) {
[item setFeedGroup:obj];
if ([(FeedGroup*)obj typ] == FEED)
[item setTarget:self action:@selector(openFeedURL:)];
} else if ([obj isKindOfClass:[FeedItem class]]) {
[item setFeedItem:obj];
} else if ([obj isKindOfClass:[FeedArticle class]]) {
[item setFeedArticle:obj];
[item setTarget:self action:@selector(openFeedURL:)];
}
@@ -243,7 +242,7 @@
- (void)finalizeMenu:(NSMenu*)menu object:(id)obj {
NSInteger unreadCount = self.unreadCountTotal; // if parent == nil
if ([menu isFeedMenu]) {
unreadCount = [(FeedItem*)obj feed].unreadCount;
unreadCount = ((FeedArticle*)obj).feed.unreadCount;
} else if (![menu isMainMenu]) {
unreadCount = [menu coreDataUnreadCount];
}
@@ -326,6 +325,7 @@
- (void)preferencesClosed:(id)sender {
[[NSNotificationCenter defaultCenter] removeObserver:self name:NSWindowWillCloseNotification object:self.prefWindow.window];
self.prefWindow = nil;
[FeedDownload scheduleNextUpdateForced:NO];
}
/**
@@ -340,7 +340,7 @@
*/
- (void)updateAllFeeds:(NSMenuItem*)sender {
// TODO: Disable 'update all' menu item during update?
[FeedDownload scheduleNextUpdate:YES];
[FeedDownload scheduleNextUpdateForced:YES];
}
/**
@@ -354,11 +354,11 @@
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
[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 (i.unread && i.link.length > 0) {
[urls addObject:[NSURL URLWithString:i.link]];
i.unread = NO;
if (fa.unread && fa.link.length > 0) {
[urls addObject:[NSURL URLWithString:fa.link]];
fa.unread = NO;
feed.unreadCount -= 1;
self.unreadCountTotal -= 1;
maxItemCount -= 1;
@@ -389,7 +389,7 @@
/**
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 {
NSManagedObjectID *oid = sender.representedObject;
@@ -398,14 +398,14 @@
NSString *url = nil;
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
id obj = [moc objectWithID:oid];
if ([obj isKindOfClass:[FeedConfig class]]) {
url = [[(FeedConfig*)obj feed] link];
} else if ([obj isKindOfClass:[FeedItem class]]) {
FeedItem *item = obj;
url = [item link];
if (item.unread) {
item.unread = NO;
item.feed.unreadCount -= 1;
if ([obj isKindOfClass:[FeedGroup class]]) {
url = ((FeedGroup*)obj).feed.link;
} else if ([obj isKindOfClass:[FeedArticle class]]) {
FeedArticle *fa = obj;
url = fa.link;
if (fa.unread) {
fa.unread = NO;
fa.feed.unreadCount -= 1;
self.unreadCountTotal -= 1;
[self updateBarIcon];
[StoreCoordinator saveContext:moc andParent:YES];

View File

@@ -32,7 +32,7 @@
- (BOOL)isMainMenu;
- (BOOL)isFeedMenu;
- (MenuItemTag)scope;
- (NSInteger)feedConfigOffset;
- (NSInteger)feedDataOffset;
- (NSInteger)coreDataUnreadCount;
// Modify menu
- (void)replaceSeparatorStringsWithActualSeparator;

View File

@@ -70,8 +70,8 @@
return ScopeGroup;
}
/// @return Index offset of the first Core Data feed item (may be separator), skipping default header and main menu header.
- (NSInteger)feedConfigOffset {
/// @return Index offset of the first core data feed item (may be separator), skipping default header and main menu header.
- (NSInteger)feedDataOffset {
for (NSInteger i = 0; i < self.numberOfItems; i++) {
if ([[[self itemAtIndex:i] representedObject] isKindOfClass:[NSManagedObjectID class]])
return i;
@@ -79,7 +79,7 @@
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 {
NSUInteger loc = [self.title rangeOfString:@"."].location;
NSString *path = nil;

View File

@@ -26,11 +26,11 @@ static NSString *kSeparatorItemTitle = @"---SEPARATOR---";
/// @c NSMenuItem options that are assigned to the @c tag attribute.
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,
/// 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,
/// Item visible at the deepest menu level (@c FeedItem elements and header)
/// Item visible at the deepest menu level @c (FeedArticle)
ScopeFeed = 8,
///
TagPreferences = (1 << 4),
@@ -44,16 +44,16 @@ typedef NS_OPTIONS(NSInteger, MenuItemTag) {
TagMaskType = 0xFFF0,
};
@class FeedConfig, Feed, FeedItem;
@class FeedGroup, Feed, FeedArticle;
@interface NSMenuItem (Feed)
+ (NSMenuItem*)itemWithTitle:(NSString*)title action:(SEL)selector target:(id)target tag:(MenuItemTag)tag;
- (NSMenuItem*)alternateWithTitle:(NSString*)title;
- (void)setTarget:(id)target action:(SEL)selector;
- (void)setFeedConfig:(FeedConfig*)config;
- (void)setFeedItem:(FeedItem*)item;
- (NSInteger)setTitleAndUnreadCount:(FeedConfig*)config;
- (void)setFeedGroup:(FeedGroup*)group;
- (void)setFeedArticle:(FeedArticle*)article;
- (NSInteger)setTitleAndUnreadCount:(FeedGroup*)group;
- (void)iterateSorted:(BOOL)ordered inContext:(NSManagedObjectContext*)moc overDescendentFeeds:(void(^)(Feed *feed, BOOL *cancel))block;
@end

View File

@@ -25,6 +25,7 @@
#import "StoreCoordinator.h"
#import "DrawImage.h"
#import "UserPrefs.h"
#import "FeedGroup+Ext.h"
/// User preferences for displaying menu items
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)
*/
- (NSInteger)setTitleAndUnreadCount:(FeedConfig*)config {
- (NSInteger)setTitleAndUnreadCount:(FeedGroup*)fg {
NSInteger uCount = 0;
if (config.typ == FEED && [UserPrefs defaultYES:@"feedUnreadCount"]) {
uCount = config.feed.unreadCount;
} else if (config.typ == GROUP && [UserPrefs defaultYES:@"groupUnreadCount"]) {
if (fg.typ == FEED && [UserPrefs defaultYES:@"feedUnreadCount"]) {
uCount = fg.feed.unreadCount;
} else if (fg.typ == GROUP && [UserPrefs defaultYES:@"groupUnreadCount"]) {
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;
}
/**
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 {
self.representedObject = config.objectID;
if (config.typ == SEPARATOR) {
- (void)setFeedGroup:(FeedGroup*)fg {
self.representedObject = fg.objectID;
if (fg.typ == SEPARATOR) {
self.title = kSeparatorItemTitle;
} else {
self.submenu = [self.menu submenuWithIndex:config.sortIndex isFeed:(config.typ == FEED)];
[self setTitleAndUnreadCount:config]; // after submenu is set
if (config.typ == FEED) {
[self configureAsFeed:config];
self.submenu = [self.menu submenuWithIndex:fg.sortIndex isFeed:(fg.typ == FEED)];
[self setTitleAndUnreadCount:fg]; // after submenu is set
if (fg.typ == FEED) {
[self configureAsFeed:fg];
} 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.toolTip = config.feed.subtitle;
self.enabled = (config.feed.items.count > 0);
self.toolTip = fg.feed.subtitle;
self.enabled = (fg.feed.articles.count > 0);
// set icon
dispatch_async(dispatch_get_main_queue(), ^{
static NSImage *defaultRSSIcon;
@@ -131,9 +132,9 @@ typedef NS_ENUM(char, DisplaySetting) {
/**
Configure menu item to be used as a container for multiple feeds.
*/
- (void)configureAsGroup:(FeedConfig*)config {
- (void)configureAsGroup:(FeedGroup*)fg {
self.tag = ScopeGroup;
self.enabled = (config.children.count > 0);
self.enabled = (fg.children.count > 0);
// set icon
dispatch_async(dispatch_get_main_queue(), ^{
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 {
self.title = item.title;
- (void)setFeedArticle:(FeedArticle*)fa {
self.title = fa.title;
self.tag = ScopeFeed;
self.enabled = (item.link.length > 0);
self.state = (item.unread ? NSControlStateValueOn : NSControlStateValueOff);
self.representedObject = item.objectID;
self.enabled = (fa.link.length > 0);
self.state = (fa.unread ? NSControlStateValueOn : NSControlStateValueOff);
self.representedObject = fa.objectID;
//mi.toolTip = item.abstract;
// 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];
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 -
/**
@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]])
return nil;
FeedConfig *config = [moc objectWithID:self.representedObject];
if (![config isKindOfClass:[FeedConfig class]])
FeedGroup *fg = [moc objectWithID:self.representedObject];
if (![fg isKindOfClass:[FeedGroup class]])
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 block Set cancel to @c YES to stop enumeration early.
*/
- (void)iterateSorted:(BOOL)ordered inContext:(NSManagedObjectContext*)moc overDescendentFeeds:(void(^)(Feed*,BOOL*))block {
if (self.parentItem) {
[[self.parentItem requestConfig:moc] iterateSorted:ordered overDescendantFeeds:block];
[[self.parentItem requestGroup:moc] iterateSorted:ordered overDescendantFeeds:block];
} else {
for (NSMenuItem *item in self.menu.itemArray) {
FeedConfig *fc = [item requestConfig:moc];
if (fc != nil) { // All groups and feeds; Ignore default header
if (![fc iterateSorted:ordered overDescendantFeeds:block])
FeedGroup *fg = [item requestGroup:moc];
if (fg != nil) { // All groups and feeds; Ignore default header
if (![fg iterateSorted:ordered overDescendantFeeds:block])
return;
}
}

View File

@@ -22,16 +22,13 @@
#import <Foundation/Foundation.h>
#import "DBv1+CoreDataModel.h"
#import "FeedConfig+Ext.h"
@class RSParsedFeed;
@interface StoreCoordinator : NSObject
// Managing contexts
+ (NSManagedObjectContext*)createChildContext;
+ (void)saveContext:(NSManagedObjectContext*)context andParent:(BOOL)flag;
// Feed update
+ (NSArray<FeedConfig*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc;
+ (NSArray<Feed*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc;
+ (NSDate*)nextScheduledUpdate;
// Feed display
+ (NSInteger)unreadCountForIndexPathString:(NSString*)str;

View File

@@ -22,16 +22,24 @@
#import "StoreCoordinator.h"
#import "AppHook.h"
#import "Feed+Ext.h"
#import <RSXML/RSXML.h>
@implementation StoreCoordinator
#pragma mark - Managing contexts -
/**
@return The application main persistent context.
*/
+ (NSManagedObjectContext*)getMainContext {
return [(AppHook*)NSApp persistentContainer].viewContext;
}
/**
New child context with @c NSMainQueueConcurrencyType and without undo manager.
*/
+ (NSManagedObjectContext*)createChildContext {
NSManagedObjectContext *context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType];
[context setParentContext:[self getMainContext]];
@@ -40,6 +48,11 @@
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 {
// 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]) {
@@ -57,14 +70,16 @@
#pragma mark - Feed Update -
+ (NSArray<FeedConfig*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc {
// TODO: Get Feed instead of FeedConfig
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedConfig.entity.name];
/**
List of @c Feed items that need to be updated. Scheduled time is now (or in past).
@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) {
// 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]];
} else {
fr.predicate = [NSPredicate predicateWithFormat:@"type = %d", FEED];
fr.predicate = [NSPredicate predicateWithFormat:@"meta.scheduled <= %@", [NSDate dateWithTimeIntervalSinceNow:+30]];
}
NSError *err;
NSArray *result = [moc executeFetchRequest:fr error:&err];
@@ -72,8 +87,11 @@
return result;
}
/**
@return @c NSDate of next (earliest) feed update. May be @c nil.
*/
+ (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];
NSExpression *exp = [NSExpression expressionForFunction:@"min:"
arguments:@[[NSExpression expressionForKeyPath:@"scheduled"]]];
@@ -82,8 +100,7 @@
[expDesc setExpression:exp];
[expDesc setExpressionResultType:NSDateAttributeType];
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedConfig.entity.name];
fr.predicate = [NSPredicate predicateWithFormat:@"type = %d", FEED];
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedMeta.entity.name];
[fr setResultType:NSDictionaryResultType];
[fr setPropertiesToFetch:@[expDesc]];
@@ -95,8 +112,13 @@
#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 {
// 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];
NSExpression *exp = [NSExpression expressionForFunction:@"sum:"
arguments:@[[NSExpression expressionForKeyPath:@"unreadCount"]]];
@@ -117,10 +139,16 @@
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 {
// NSManagedObjectContext *moc = [self getMainContext];
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: (flag ? FeedItem.entity : FeedConfig.entity).name];
fr.predicate = [NSPredicate predicateWithFormat:(flag ? @"feed.config = %@" : @"parent = %@"), parent];
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: (flag ? FeedArticle.entity : FeedGroup.entity).name];
fr.predicate = [NSPredicate predicateWithFormat:(flag ? @"feed.group = %@" : @"parent = %@"), parent];
fr.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:!flag]];
[fr setResultType:NSManagedObjectIDResultType];
@@ -130,30 +158,24 @@
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 -
/**
Delete all @c Feed items where @c group @c = @c NULL.
*/
+ (void)deleteUnreferencedFeeds {
NSManagedObjectContext *moc = [self getMainContext];
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName:Feed.entity.name];
fr.predicate = [NSPredicate predicateWithFormat:@"config = NULL"];
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
fr.predicate = [NSPredicate predicateWithFormat:@"group = NULL"];
NSBatchDeleteRequest *bdr = [[NSBatchDeleteRequest alloc] initWithFetchRequest:fr];
NSError *err;
[moc executeRequest:bdr error:&err];
if (err) NSLog(@"%@", err);
}
/**
Iterate over all @c Feed and re-calculate @c unreadCount, @c articleCount and @c indexPath.
*/
+ (void)restoreFeedCountsAndIndexPaths {
NSManagedObjectContext *moc = [self getMainContext];
NSError *err;
@@ -161,16 +183,13 @@
if (err) NSLog(@"%@", err);
[moc performBlock:^{
for (Feed *feed in result) {
int16_t totalCount = (int16_t)feed.items.count;
int16_t unreadCount = (int16_t)[[feed.items valueForKeyPath:@"@sum.unread"] integerValue];
int16_t totalCount = (int16_t)feed.articles.count;
int16_t unreadCount = (int16_t)[[feed.articles valueForKeyPath:@"@sum.unread"] integerValue];
if (feed.articleCount != totalCount)
feed.articleCount = totalCount;
if (feed.unreadCount != unreadCount)
feed.unreadCount = unreadCount; // remember to update global total unread count
NSString *pathStr = [feed.config indexPathString];
if (![feed.indexPath isEqualToString:pathStr])
feed.indexPath = pathStr;
[feed calculateAndSetIndexPathString];
}
}];
}