diff --git a/README.md b/README.md index 9fbb371..b61c18d 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/baRSS.xcodeproj/project.pbxproj b/baRSS.xcodeproj/project.pbxproj index a9258a9..68f6c4f 100644 --- a/baRSS.xcodeproj/project.pbxproj +++ b/baRSS.xcodeproj/project.pbxproj @@ -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 = ""; }; + 540F704421B6C16C0022E69D /* FeedMeta+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "FeedMeta+Ext.m"; sourceTree = ""; }; 54195881218A061100581B79 /* Feed+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Feed+Ext.h"; sourceTree = ""; }; 54195882218A061100581B79 /* Feed+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "Feed+Ext.m"; sourceTree = ""; }; 54195884218E1BDB00581B79 /* NSMenu+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSMenu+Ext.h"; sourceTree = ""; }; @@ -94,8 +97,8 @@ 546FC44121189975007CC3A3 /* SettingsGeneral.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SettingsGeneral.m; sourceTree = ""; }; 546FC44221189975007CC3A3 /* SettingsGeneral.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SettingsGeneral.xib; sourceTree = ""; }; 546FC4462118A8E6007CC3A3 /* Preferences.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = Preferences.xib; sourceTree = ""; }; - 5477D34C21233C62002BA27F /* FeedConfig+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FeedConfig+Ext.h"; sourceTree = ""; }; - 5477D34D21233C62002BA27F /* FeedConfig+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "FeedConfig+Ext.m"; sourceTree = ""; }; + 5477D34C21233C62002BA27F /* FeedGroup+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FeedGroup+Ext.h"; sourceTree = ""; }; + 5477D34D21233C62002BA27F /* FeedGroup+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "FeedGroup+Ext.m"; sourceTree = ""; }; 5496B50F214D6275003ED4ED /* UserPrefs.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UserPrefs.h; sourceTree = ""; }; 5496B510214D6275003ED4ED /* UserPrefs.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UserPrefs.m; sourceTree = ""; }; 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 = ""; @@ -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 */, diff --git a/baRSS/Categories/Feed+Ext.h b/baRSS/Categories/Feed+Ext.h index 92d9fde..64057e1 100644 --- a/baRSS/Categories/Feed+Ext.h +++ b/baRSS/Categories/Feed+Ext.h @@ -25,9 +25,12 @@ @class RSParsedFeed; @interface Feed (Ext) -- (void)updateWithRSS:(RSParsedFeed*)obj; -- (NSArray*)sortedArticles; - +// Generator methods / Feed update ++ (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context; +- (void)calculateAndSetIndexPathString; +- (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag; +// Article properties +- (NSArray*)sortedArticles; - (int)markAllItemsRead; - (int)markAllItemsUnread; @end diff --git a/baRSS/Categories/Feed+Ext.m b/baRSS/Categories/Feed+Ext.m index ad68d6b..9751094 100644 --- a/baRSS/Categories/Feed+Ext.m +++ b/baRSS/Categories/Feed+Ext.m @@ -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 @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 *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 *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*)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*)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*)sortedArticles { - if (self.items.count == 0) +- (NSArray*)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); diff --git a/baRSS/Categories/FeedConfig+Ext.h b/baRSS/Categories/FeedGroup+Ext.h similarity index 75% rename from baRSS/Categories/FeedConfig+Ext.h rename to baRSS/Categories/FeedGroup+Ext.h index 8a482d9..c66d18c 100644 --- a/baRSS/Categories/FeedConfig+Ext.h +++ b/baRSS/Categories/FeedGroup+Ext.h @@ -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*)allParents; +- (NSMutableArray*)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 diff --git a/baRSS/Categories/FeedConfig+Ext.m b/baRSS/Categories/FeedGroup+Ext.m similarity index 54% rename from baRSS/Categories/FeedConfig+Ext.m rename to baRSS/Categories/FeedGroup+Ext.m index ef432eb..8858041 100644 --- a/baRSS/Categories/FeedConfig+Ext.m +++ b/baRSS/Categories/FeedGroup+Ext.m @@ -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*)sortedChildren { +- (NSArray*)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*)allParents { +/// @return @c NSArray of all ancestors: First object is root. Last object is the @c FeedGroup that executed the command. +- (NSMutableArray*)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]; } } diff --git a/baRSS/Categories/FeedMeta+Ext.h b/baRSS/Categories/FeedMeta+Ext.h new file mode 100644 index 0000000..5e19e54 --- /dev/null +++ b/baRSS/Categories/FeedMeta+Ext.h @@ -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 diff --git a/baRSS/Categories/FeedMeta+Ext.m b/baRSS/Categories/FeedMeta+Ext.m new file mode 100644 index 0000000..feb5d37 --- /dev/null +++ b/baRSS/Categories/FeedMeta+Ext.m @@ -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 diff --git a/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents b/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents index a2dbab6..12feb10 100644 --- a/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents +++ b/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents @@ -7,24 +7,12 @@ - - + + + + - - - - - - - - - - - - - - - + @@ -34,18 +22,36 @@ - + + + + + + + + + + + + + + - - - - + + + + + + + + - - - - + + + + + \ No newline at end of file diff --git a/baRSS/FeedDownload.h b/baRSS/FeedDownload.h index fd0aa9f..c67e4e5 100644 --- a/baRSS/FeedDownload.h +++ b/baRSS/FeedDownload.h @@ -28,5 +28,5 @@ + (void)registerNetworkChangeNotification; + (void)unregisterNetworkChangeNotification; + (BOOL)isNetworkReachable; -+ (void)scheduleNextUpdate:(BOOL)forceUpdate; ++ (void)scheduleNextUpdateForced:(BOOL)flag; @end diff --git a/baRSS/FeedDownload.m b/baRSS/FeedDownload.m index 2d1f529..73d335f 100644 --- a/baRSS/FeedDownload.m +++ b/baRSS/FeedDownload.m @@ -23,6 +23,9 @@ #import "FeedDownload.h" #import "Constants.h" #import "StoreCoordinator.h" +#import "Feed+Ext.h" +#import "FeedMeta+Ext.h" + #import 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 *list = [StoreCoordinator getListOfFeedsThatNeedUpdate:forceAll inContext:childContext]; + NSArray *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? // - [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; diff --git a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.h b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.h index ea8bb3a..9af1928 100644 --- a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.h +++ b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.h @@ -21,22 +21,20 @@ // SOFTWARE. #import +#import "ModalSheet.h" -@class FeedConfig; +@class FeedGroup; -@protocol ModalEditDelegate -- (void)modalDidUpdateFeedConfig:(FeedConfig*)config; -@end - -@protocol ModalFeedConfigEdit -@property (weak) id 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 +@interface ModalFeedEdit : ModalEditDialog @end -@interface ModalGroupEdit : NSViewController +@interface ModalGroupEdit : ModalEditDialog @end diff --git a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m index 6f9dac0..adbf1c9 100644 --- a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m +++ b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m @@ -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]; +} + +/** + 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]; +} + +#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]; + } +} + +/** + 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]; - 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]; - - self.previousURL = self.url.stringValue; + [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)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]; - } - } -} - -- (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]; diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeeds.m b/baRSS/Preferences/Feeds Tab/SettingsFeeds.m index fa8ea33..d00b70a 100644 --- a/baRSS/Preferences/Feeds Tab/SettingsFeeds.m +++ b/baRSS/Preferences/Feeds Tab/SettingsFeeds.m @@ -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 () +@interface SettingsFeeds () @property (weak) IBOutlet NSOutlineView *outlineView; @property (weak) IBOutlet NSTreeController *dataStore; -@property (strong) NSViewController *modalController; @property (strong) NSArray *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 *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 -} - -- (void)showModalForFeedConfig:(FeedConfig*)obj isGroupEdit:(BOOL)group { - BOOL existingItem = [obj isKindOfClass:[FeedConfig class]]; - if (existingItem) { - if (obj.typ == SEPARATOR) return; - group = (obj.typ == GROUP); +/** + 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. + + @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 *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 )info item:(id)item childIndex:(NSInteger)index { NSTreeNode *destParent = (item != nil ? item : [self.dataStore arrangedObjects]); NSUInteger idx = (NSUInteger)index; @@ -223,17 +229,16 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; [self restoreOrderingAndIndexPathStr:node]; } [self restoreOrderingAndIndexPathStr:destParent]; - + return YES; } +/// Validate method whether items can be dropped at destination - (NSDragOperation)outlineView:(NSOutlineView *)outlineView validateDrop:(id )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; diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeeds.xib b/baRSS/Preferences/Feeds Tab/SettingsFeeds.xib index 5b354a4..37e09ad 100644 --- a/baRSS/Preferences/Feeds Tab/SettingsFeeds.xib +++ b/baRSS/Preferences/Feeds Tab/SettingsFeeds.xib @@ -15,7 +15,7 @@ - + diff --git a/baRSS/Preferences/General Tab/SettingsGeneral.m b/baRSS/Preferences/General Tab/SettingsGeneral.m index b906f61..dcf27cb 100644 --- a/baRSS/Preferences/General Tab/SettingsGeneral.m +++ b/baRSS/Preferences/General Tab/SettingsGeneral.m @@ -25,8 +25,8 @@ #import "BarMenu.h" #import "UserPrefs.h" #import "StoreCoordinator.h" -#import +#import @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"); diff --git a/baRSS/Preferences/General Tab/UserPrefs.m b/baRSS/Preferences/General Tab/UserPrefs.m index 9561651..5cfbee7 100644 --- a/baRSS/Preferences/General Tab/UserPrefs.m +++ b/baRSS/Preferences/General Tab/UserPrefs.m @@ -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"]; } diff --git a/baRSS/Preferences/ModalSheet.h b/baRSS/Preferences/ModalSheet.h index 2c9aac1..4cd225c 100644 --- a/baRSS/Preferences/ModalSheet.h +++ b/baRSS/Preferences/ModalSheet.h @@ -23,6 +23,8 @@ #import @interface ModalSheet : NSPanel +@property (readonly) BOOL closeInitiated; + + (instancetype)modalWithView:(NSView*)content; - (void)setDoneEnabled:(BOOL)accept; @end diff --git a/baRSS/Preferences/ModalSheet.m b/baRSS/Preferences/ModalSheet.m index 0622077..4a664db 100644 --- a/baRSS/Preferences/ModalSheet.m +++ b/baRSS/Preferences/ModalSheet.m @@ -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; diff --git a/baRSS/Preferences/Preferences.m b/baRSS/Preferences/Preferences.m index ae3666b..f7c0dc2 100644 --- a/baRSS/Preferences/Preferences.m +++ b/baRSS/Preferences/Preferences.m @@ -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 diff --git a/baRSS/Status Bar Menu/BarMenu.h b/baRSS/Status Bar Menu/BarMenu.h index 1db2afb..ce2debd 100644 --- a/baRSS/Status Bar Menu/BarMenu.h +++ b/baRSS/Status Bar Menu/BarMenu.h @@ -24,5 +24,5 @@ @interface BarMenu : NSObject - (void)updateBarIcon; -- (void)reloadUnreadCountAndUpdateBarIcon; +- (void)asyncReloadUnreadCountAndUpdateBarIcon; @end diff --git a/baRSS/Status Bar Menu/BarMenu.m b/baRSS/Status Bar Menu/BarMenu.m index e761a06..3a090c2 100644 --- a/baRSS/Status Bar Menu/BarMenu.m +++ b/baRSS/Status Bar Menu/BarMenu.m @@ -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]; diff --git a/baRSS/Status Bar Menu/NSMenu+Ext.h b/baRSS/Status Bar Menu/NSMenu+Ext.h index e0b1529..6ac3afd 100644 --- a/baRSS/Status Bar Menu/NSMenu+Ext.h +++ b/baRSS/Status Bar Menu/NSMenu+Ext.h @@ -32,7 +32,7 @@ - (BOOL)isMainMenu; - (BOOL)isFeedMenu; - (MenuItemTag)scope; -- (NSInteger)feedConfigOffset; +- (NSInteger)feedDataOffset; - (NSInteger)coreDataUnreadCount; // Modify menu - (void)replaceSeparatorStringsWithActualSeparator; diff --git a/baRSS/Status Bar Menu/NSMenu+Ext.m b/baRSS/Status Bar Menu/NSMenu+Ext.m index 585bf0f..9c23d1f 100644 --- a/baRSS/Status Bar Menu/NSMenu+Ext.m +++ b/baRSS/Status Bar Menu/NSMenu+Ext.m @@ -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; diff --git a/baRSS/Status Bar Menu/NSMenuItem+Ext.h b/baRSS/Status Bar Menu/NSMenuItem+Ext.h index ee684ef..5fb5e20 100644 --- a/baRSS/Status Bar Menu/NSMenuItem+Ext.h +++ b/baRSS/Status Bar Menu/NSMenuItem+Ext.h @@ -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 diff --git a/baRSS/Status Bar Menu/NSMenuItem+Ext.m b/baRSS/Status Bar Menu/NSMenuItem+Ext.m index b544fe2..6dedffa 100644 --- a/baRSS/Status Bar Menu/NSMenuItem+Ext.m +++ b/baRSS/Status Bar Menu/NSMenuItem+Ext.m @@ -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; } } diff --git a/baRSS/StoreCoordinator.h b/baRSS/StoreCoordinator.h index aa0443d..7afefbc 100644 --- a/baRSS/StoreCoordinator.h +++ b/baRSS/StoreCoordinator.h @@ -22,16 +22,13 @@ #import #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*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc; ++ (NSArray*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc; + (NSDate*)nextScheduledUpdate; // Feed display + (NSInteger)unreadCountForIndexPathString:(NSString*)str; diff --git a/baRSS/StoreCoordinator.m b/baRSS/StoreCoordinator.m index 49e03bf..8ff4b2e 100644 --- a/baRSS/StoreCoordinator.m +++ b/baRSS/StoreCoordinator.m @@ -22,16 +22,24 @@ #import "StoreCoordinator.h" #import "AppHook.h" +#import "Feed+Ext.h" + #import @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*)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*)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]; } }]; }