diff --git a/baRSS.xcodeproj/project.pbxproj b/baRSS.xcodeproj/project.pbxproj index f61aa49..a9258a9 100644 --- a/baRSS.xcodeproj/project.pbxproj +++ b/baRSS.xcodeproj/project.pbxproj @@ -7,9 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 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 */; }; - 543695D5214EFD9800DA979D /* NSMenuItem+Info.m in Sources */ = {isa = PBXBuildFile; fileRef = 543695D4214EFD9800DA979D /* NSMenuItem+Info.m */; }; - 543695D8214F1F2700DA979D /* NSMenuItem+Generate.m in Sources */ = {isa = PBXBuildFile; fileRef = 543695D7214F1F2700DA979D /* NSMenuItem+Generate.m */; }; + 543695D8214F1F2700DA979D /* NSMenuItem+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 543695D7214F1F2700DA979D /* NSMenuItem+Ext.m */; }; 544B011A2114B41200386E5C /* ModalSheet.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B01192114B41200386E5C /* ModalSheet.m */; }; 544B011D2114EE9100386E5C /* AppHook.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B011C2114EE9100386E5C /* AppHook.m */; }; 544DCCB9212A2B4D002DBC46 /* RSXML.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 544DCCB8212A2B4D002DBC46 /* RSXML.framework */; }; @@ -71,12 +72,15 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 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 = ""; }; + 54195885218E1BDB00581B79 /* NSMenu+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSMenu+Ext.m"; sourceTree = ""; }; + 541958872190FF1200581B79 /* Constants.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Constants.h; sourceTree = ""; }; 54209E922117325100F3B5EF /* DrawImage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DrawImage.h; sourceTree = ""; }; 54209E932117325100F3B5EF /* DrawImage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DrawImage.m; sourceTree = ""; }; - 543695D3214EFD9800DA979D /* NSMenuItem+Info.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSMenuItem+Info.h"; sourceTree = ""; }; - 543695D4214EFD9800DA979D /* NSMenuItem+Info.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSMenuItem+Info.m"; sourceTree = ""; }; - 543695D6214F1F2700DA979D /* NSMenuItem+Generate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSMenuItem+Generate.h"; sourceTree = ""; }; - 543695D7214F1F2700DA979D /* NSMenuItem+Generate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSMenuItem+Generate.m"; sourceTree = ""; }; + 543695D6214F1F2700DA979D /* NSMenuItem+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSMenuItem+Ext.h"; sourceTree = ""; }; + 543695D7214F1F2700DA979D /* NSMenuItem+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSMenuItem+Ext.m"; sourceTree = ""; }; 544B01182114B41200386E5C /* ModalSheet.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ModalSheet.h; sourceTree = ""; }; 544B01192114B41200386E5C /* ModalSheet.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ModalSheet.m; sourceTree = ""; }; 544B011B2114EE9100386E5C /* AppHook.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppHook.h; sourceTree = ""; }; @@ -136,13 +140,24 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 54195880218A05E700581B79 /* Categories */ = { + isa = PBXGroup; + children = ( + 5477D34C21233C62002BA27F /* FeedConfig+Ext.h */, + 5477D34D21233C62002BA27F /* FeedConfig+Ext.m */, + 54195881218A061100581B79 /* Feed+Ext.h */, + 54195882218A061100581B79 /* Feed+Ext.m */, + ); + path = Categories; + sourceTree = ""; + }; 541A90EF21257D4F002680A6 /* Status Bar Menu */ = { isa = PBXGroup; children = ( - 543695D3214EFD9800DA979D /* NSMenuItem+Info.h */, - 543695D4214EFD9800DA979D /* NSMenuItem+Info.m */, - 543695D6214F1F2700DA979D /* NSMenuItem+Generate.h */, - 543695D7214F1F2700DA979D /* NSMenuItem+Generate.m */, + 543695D6214F1F2700DA979D /* NSMenuItem+Ext.h */, + 543695D7214F1F2700DA979D /* NSMenuItem+Ext.m */, + 54195884218E1BDB00581B79 /* NSMenu+Ext.h */, + 54195885218E1BDB00581B79 /* NSMenu+Ext.m */, 54FE73D1212316CD003EAC65 /* BarMenu.h */, 54FE73D2212316CD003EAC65 /* BarMenu.m */, ); @@ -174,8 +189,6 @@ 546FC44D2118B357007CC3A3 /* Preferences */ = { isa = PBXGroup; children = ( - 5477D34C21233C62002BA27F /* FeedConfig+Ext.h */, - 5477D34D21233C62002BA27F /* FeedConfig+Ext.m */, 54ACC29621061FBA0020715F /* Preferences.h */, 54ACC29721061FBA0020715F /* Preferences.m */, 546FC4462118A8E6007CC3A3 /* Preferences.xib */, @@ -211,6 +224,7 @@ children = ( 544B011B2114EE9100386E5C /* AppHook.h */, 544B011C2114EE9100386E5C /* AppHook.m */, + 541958872190FF1200581B79 /* Constants.h */, 541A90EF21257D4F002680A6 /* Status Bar Menu */, 54FE73CE21220DEC003EAC65 /* StoreCoordinator.h */, 54FE73CF21220DEC003EAC65 /* StoreCoordinator.m */, @@ -218,6 +232,7 @@ 54ACC29421061E270020715F /* FeedDownload.m */, 54209E922117325100F3B5EF /* DrawImage.h */, 54209E932117325100F3B5EF /* DrawImage.m */, + 54195880218A05E700581B79 /* Categories */, 546FC44D2118B357007CC3A3 /* Preferences */, 54ACC28521061B3C0020715F /* Assets.xcassets */, 54ACC28A21061B3C0020715F /* Info.plist */, @@ -366,7 +381,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 543695D8214F1F2700DA979D /* NSMenuItem+Generate.m in Sources */, + 543695D8214F1F2700DA979D /* NSMenuItem+Ext.m in Sources */, 54F39C2E210BE1F700AEE730 /* DBv1.xcdatamodeld in Sources */, 544B011D2114EE9100386E5C /* AppHook.m in Sources */, 546FC44321189975007CC3A3 /* SettingsGeneral.m in Sources */, @@ -375,11 +390,12 @@ 54ACC28C21061B3C0020715F /* main.m in Sources */, 54FE73D3212316CD003EAC65 /* BarMenu.m in Sources */, 544B011A2114B41200386E5C /* ModalSheet.m in Sources */, - 543695D5214EFD9800DA979D /* NSMenuItem+Info.m in Sources */, 54ACC29821061FBA0020715F /* Preferences.m in Sources */, + 54195886218E1BDB00581B79 /* NSMenu+Ext.m in Sources */, 5496B511214D6275003ED4ED /* UserPrefs.m in Sources */, 546FC43D21188AD5007CC3A3 /* SettingsFeeds.m in Sources */, 54E88320211B509D00064188 /* ModalFeedEdit.m in Sources */, + 54195883218A061100581B79 /* Feed+Ext.m in Sources */, 54209E942117325100F3B5EF /* DrawImage.m in Sources */, 54FE73D021220DEC003EAC65 /* StoreCoordinator.m in Sources */, ); diff --git a/baRSS/Categories/Feed+Ext.h b/baRSS/Categories/Feed+Ext.h new file mode 100644 index 0000000..2fbd6e0 --- /dev/null +++ b/baRSS/Categories/Feed+Ext.h @@ -0,0 +1,32 @@ +// +// 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 "Feed+CoreDataClass.h" + +@class RSParsedFeed; + +@interface Feed (Ext) ++ (Feed*)feedFromRSS:(RSParsedFeed*)obj inContext:(NSManagedObjectContext*)context alreadyRead:(NSArray*)urls unread:(int*)unreadCount; +- (NSArray*)alreadyReadURLs; +- (void)markAllItemsRead; +- (void)markAllItemsUnread; +@end diff --git a/baRSS/Categories/Feed+Ext.m b/baRSS/Categories/Feed+Ext.m new file mode 100644 index 0000000..588e3b5 --- /dev/null +++ b/baRSS/Categories/Feed+Ext.m @@ -0,0 +1,89 @@ +// +// 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 "Feed+Ext.h" +#import "FeedConfig+Ext.h" +#import "FeedItem+CoreDataClass.h" +#import + +@implementation Feed (Ext) + ++ (FeedItem*)createFeedItemFrom:(RSParsedArticle*)entry inContext:(NSManagedObjectContext*)context { + FeedItem *b = [[FeedItem alloc] initWithEntity:FeedItem.entity insertIntoManagedObjectContext:context]; + 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; + return b; +} + ++ (Feed*)feedFromRSS:(RSParsedFeed*)obj inContext:(NSManagedObjectContext*)context alreadyRead:(NSArray*)urls unread:(int*)unreadCount { + Feed *a = [[Feed alloc] initWithEntity:Feed.entity insertIntoManagedObjectContext:context]; + a.title = obj.title; + a.subtitle = obj.subtitle; + a.link = obj.link; + for (RSParsedArticle *article in obj.articles) { + FeedItem *b = [self createFeedItemFrom:article inContext:context]; + if ([urls containsObject:b.link]) { + b.unread = NO; + } else { + *unreadCount += 1; + } + [a addItemsObject:b]; + } + return a; +} + +- (NSArray*)alreadyReadURLs { + if (!self.items || self.items.count == 0) return nil; + NSMutableArray *mArr = [NSMutableArray arrayWithCapacity:self.items.count]; + for (FeedItem *f in self.items) { + if (!f.unread) { + [mArr addObject:f.link]; + } + } + return mArr; +} + +- (void)markAllItemsRead { + [self markAllArticlesRead:YES]; +} + +- (void)markAllItemsUnread { + [self markAllArticlesRead:NO]; +} + +- (void)markAllArticlesRead:(BOOL)readFlag { + int count = 0; + for (FeedItem *i in self.items) { + if (i.unread == readFlag) { + i.unread = !readFlag; + ++count; + } + } + [self.config markUnread:(readFlag ? -count : +count) ancestorsOnly:NO]; +} + +@end diff --git a/baRSS/Preferences/FeedConfig+Ext.h b/baRSS/Categories/FeedConfig+Ext.h similarity index 72% rename from baRSS/Preferences/FeedConfig+Ext.h rename to baRSS/Categories/FeedConfig+Ext.h index a9301c4..17f8f53 100644 --- a/baRSS/Preferences/FeedConfig+Ext.h +++ b/baRSS/Categories/FeedConfig+Ext.h @@ -22,7 +22,7 @@ #import "FeedConfig+CoreDataClass.h" -@class FeedItem; +@class FeedItem, RSParsedFeed; @interface FeedConfig (Ext) /// Enum type to distinguish different @c FeedConfig types @@ -31,22 +31,18 @@ typedef enum int16_t { FEED = 1, SEPARATOR = 2 } FeedConfigType; -/** - Iteration block for descendants of @c FeedItem. - - @param parent The parent @c FeedConfig where this @c FeedItem belongs to. - @param item Currently processed @c FeedItem. - @return Return @c YES to continue processing. Return @c NO to stop processing and exit early. - */ -typedef BOOL (^FeedConfigRecursiveItemsBlock) (FeedConfig *parent, FeedItem *item); @property (getter=typ, setter=setTyp:) FeedConfigType typ; -@property (readonly) NSArray *sortedChildren; -@property (readonly) NSIndexPath *indexPath; -- (BOOL)descendantFeedItems:(FeedConfigRecursiveItemsBlock)block; +- (NSArray*)sortedChildren; +- (NSIndexPath*)indexPath; +- (void)markUnread:(int)count ancestorsOnly:(BOOL)flag; - (void)calculateAndSetScheduled; -- (void)mergeChangesAndSave; +- (BOOL)iterateSorted:(BOOL)ordered overDescendantFeeds:(void(^)(Feed*,BOOL*))block; + +- (void)setEtag:(NSString*)etag modified:(NSString*)modified; +- (void)updateRSSFeed:(RSParsedFeed*)obj; + - (NSString*)readableRefreshString; - (NSString*)readableDescription; @end diff --git a/baRSS/Preferences/FeedConfig+Ext.m b/baRSS/Categories/FeedConfig+Ext.m similarity index 56% rename from baRSS/Preferences/FeedConfig+Ext.m rename to baRSS/Categories/FeedConfig+Ext.m index c3cc52a..41fb005 100644 --- a/baRSS/Preferences/FeedConfig+Ext.m +++ b/baRSS/Categories/FeedConfig+Ext.m @@ -21,7 +21,9 @@ // SOFTWARE. #import "FeedConfig+Ext.h" -#import "Feed+CoreDataClass.h" +#import "Feed+Ext.h" +#import "FeedMeta+CoreDataClass.h" +#import "Constants.h" @implementation FeedConfig (Ext) /// Enum tpye getter see @c FeedConfigType @@ -34,37 +36,34 @@ @return Sorted array of @c FeedConfig items. */ -- (NSArray *)sortedChildren { +- (NSArray*)sortedChildren { if (self.children.count == 0) return nil; return [self.children sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]]]; } -- (NSIndexPath *)indexPath { +/// IndexPath for sorted children starting with root index. +- (NSIndexPath*)indexPath { if (self.parent == nil) return [NSIndexPath indexPathWithIndex:(NSUInteger)self.sortIndex]; - return [self.parent.indexPath indexPathByAddingIndex:(NSUInteger)self.sortIndex]; + return [[self.parent indexPath] indexPathByAddingIndex:(NSUInteger)self.sortIndex]; } /** - Iterate over all descendant @c FeedItems in sub groups - - @param block Will yield the current parent config and feed item. Return @c NO to cancel iteration. - @return Returns @c NO if the iteration was canceled early. Otherwise @c YES. + Change unread counter for all parents recursively. Result will never be negative. + + @param count If negative, mark items read. */ -- (BOOL)descendantFeedItems:(FeedConfigRecursiveItemsBlock)block { - if (self.children.count > 0) { - for (FeedConfig *config in self.sortedChildren) { - if ([config descendantFeedItems:block] == NO) - return NO; - } - } else if (self.feed.items.count > 0) { - for (FeedItem* item in self.feed.items) { - if (block(self, item) == NO) - return NO; - } +- (void)markUnread:(int)count ancestorsOnly:(BOOL)flag { + FeedConfig *par = (flag ? self.parent : self); + while (par) { + [self.managedObjectContext refreshObject:par mergeChanges:YES]; + par.unreadCount += count; + NSAssert(par.unreadCount >= 0, @"ERROR ancestorsMarkUnread: Count should never be negative."); + par = par.parent; } - return YES; + [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged + object:[NSNumber numberWithInt:count]]; } /// @return Time interval respecting the selected unit. E.g., returns @c 180 for @c '3m' @@ -78,14 +77,46 @@ self.scheduled = [[NSDate date] dateByAddingTimeInterval:[self timeInterval]]; } -/// Update item with @c mergeChanges:YES and save the context -- (void)mergeChangesAndSave { - [self.managedObjectContext performBlockAndWait:^{ - [self.managedObjectContext refreshObject:self mergeChanges:YES]; - [self.managedObjectContext save:nil]; - }]; +/// Update FeedMeta or create new one if needed. +- (void)setEtag:(NSString*)etag modified:(NSString*)modified { + // TODO: move to separate function and add icon download + if (!self.meta) { + self.meta = [[FeedMeta alloc] initWithEntity:FeedMeta.entity insertIntoManagedObjectContext:self.managedObjectContext]; + } + self.meta.httpEtag = etag; + self.meta.httpModified = modified; } +/// Delete any existing feed object and parse new one. Read state will be copied. +- (void)updateRSSFeed:(RSParsedFeed*)obj { + NSArray *readURLs = [self.feed alreadyReadURLs]; + int unreadBefore = self.unreadCount; + int unreadAfter = 0; + if (self.feed) + [self.managedObjectContext deleteObject:(NSManagedObject*)self.feed]; + if (obj) { + // TODO: update and dont re-create each time + self.feed = [Feed feedFromRSS:obj inContext:self.managedObjectContext alreadyRead:readURLs unread:&unreadAfter]; + } + [self markUnread:(unreadAfter - unreadBefore) ancestorsOnly:NO]; +} + +- (BOOL)iterateSorted:(BOOL)ordered overDescendantFeeds:(void(^)(Feed*,BOOL*))block { + if (self.feed) { + BOOL stopEarly = NO; + block(self.feed, &stopEarly); + if (stopEarly) return NO; + } else { + for (FeedConfig *fc in (ordered ? [self sortedChildren] : self.children)) { + if (![fc iterateSorted:ordered overDescendantFeeds:block]) + return NO; + } + } + return YES; +} + +#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]]; diff --git a/baRSS/Constants.h b/baRSS/Constants.h new file mode 100644 index 0000000..d56429e --- /dev/null +++ b/baRSS/Constants.h @@ -0,0 +1,30 @@ +// +// 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. + +#ifndef Constants_h +#define Constants_h + +static NSString *kNotificationFeedUpdated = @"baRSS-notification-feed-updated"; +static NSString *kNotificationNetworkStatusChanged = @"baRSS-notification-network-status-changed"; +static NSString *kNotificationTotalUnreadCountChanged = @"baRSS-notification-total-unread-count-changed"; + +#endif /* Constants_h */ diff --git a/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents b/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents index 66f0c83..9e64484 100644 --- a/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents +++ b/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -15,6 +15,7 @@ + @@ -40,7 +41,7 @@ - + diff --git a/baRSS/FeedDownload.m b/baRSS/FeedDownload.m index 62f17bc..ed7823f 100644 --- a/baRSS/FeedDownload.m +++ b/baRSS/FeedDownload.m @@ -21,6 +21,7 @@ // SOFTWARE. #import "FeedDownload.h" +#import "Constants.h" #import "StoreCoordinator.h" #import @@ -95,21 +96,25 @@ static BOOL _isReachable = NO; BOOL forceAll = [timer.userInfo boolValue]; // TODO: check internet connection // TODO: disable menu item 'update all' during update - NSArray *list = [StoreCoordinator getListOfFeedsThatNeedUpdate:forceAll]; + __block NSManagedObjectContext *childContext = [StoreCoordinator createChildContext]; + 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 return; // nothing to do here } - NSUndoManager *um = list.firstObject.managedObjectContext.undoManager; - [um beginUndoGrouping]; dispatch_group_t group = dispatch_group_create(); for (FeedConfig *c in list) { [self downloadFeedForConfig:c group:group]; } dispatch_group_notify(group, dispatch_get_main_queue(), ^{ - [um endUndoGrouping]; + [StoreCoordinator saveContext:childContext andParent:YES]; + [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:nil]; + [childContext reset]; + childContext = nil; [self scheduleNextUpdate:NO]; // after forced update, continue regular cycle }); } @@ -118,20 +123,21 @@ static BOOL _isReachable = NO; 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.undoManager beginUndoGrouping]; - 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]; - // TODO: remove logging - NSLog(@"Error loading: %@ (%d)", response.URL, config.errorCount); - } else { - config.errorCount = 0; // reset counter - [self downloadSuccessful:data forFeed:config response:(NSHTTPURLResponse*)response]; - } - [config.managedObjectContext.undoManager endUndoGrouping]; - dispatch_group_leave(group); + [config.managedObjectContext performBlock:^{ + // core data block inside of url session block; otherwise config 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]; + // TODO: remove logging + NSLog(@"Error loading: %@ (%d)", response.URL, config.errorCount); + } else { + config.errorCount = 0; // reset counter + [self downloadSuccessful:data forFeed:config response:(NSHTTPURLResponse*)response]; + } + dispatch_group_leave(group); + }]; }] resume]; } @@ -143,15 +149,16 @@ static BOOL _isReachable = NO; if (parsed) { // TODO: add support for media player? // - [StoreCoordinator overwriteConfig:config withFeed:parsed]; + [config updateRSSFeed:parsed]; } } - config.meta.httpModified = [http allHeaderFields][@"Date"]; // @"Expires", @"Last-Modified" - config.meta.httpEtag = [http allHeaderFields][@"Etag"]; + [config 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]; - [[NSNotificationCenter defaultCenter] postNotificationName:@"baRSS-notification-feed-updated" object:config]; +// [config mergeChangesAndSave]; +// [config.managedObjectContext performBlock:^{ +// [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:config.objectID]; +// }]; } @@ -190,7 +197,7 @@ static void networkReachabilityCallback(SCNetworkReachabilityRef target, SCNetwo if (_reachability == NULL) return; _isReachable = [FeedDownload hasConnectivity:flags]; - [[NSNotificationCenter defaultCenter] postNotificationName:@"baRSS-notification-network-status-change" + [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationNetworkStatusChanged object:[NSNumber numberWithBool:_isReachable]]; if (_isReachable) { NSLog(@"reachable"); diff --git a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m index bba594d..accb5fc 100644 --- a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m +++ b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m @@ -109,22 +109,13 @@ item.refreshUnit = (int16_t)self.refreshUnit.indexOfSelectedItem; if (self.shouldDeletePrevArticles) { - [StoreCoordinator overwriteConfig:item withFeed:self.feedResult]; - [item.managedObjectContext performBlockAndWait:^{ - // TODO: move to separate function and add icon download - if (!item.meta) { - item.meta = [[FeedMeta alloc] initWithEntity:FeedMeta.entity insertIntoManagedObjectContext:item.managedObjectContext]; - } - item.meta.httpEtag = self.httpEtag; - item.meta.httpModified = self.httpDate; - }]; + [item updateRSSFeed:self.feedResult]; + [item setEtag:self.httpEtag modified:self.httpDate]; } if ([item.managedObjectContext hasChanges]) { self.objectIsModified = YES; [item calculateAndSetScheduled]; - [item.managedObjectContext performBlockAndWait:^{ - [item.managedObjectContext refreshObject:item mergeChanges:YES]; - }]; + [item.managedObjectContext refreshObject:item mergeChanges:YES]; } } @@ -222,9 +213,7 @@ NSString *name = ((NSTextField*)self.view).stringValue; if (![item.name isEqualToString: name]) { item.name = name; - [item.managedObjectContext performBlockAndWait:^{ - [item.managedObjectContext refreshObject:item mergeChanges:YES]; - }]; + [item.managedObjectContext refreshObject:item mergeChanges:YES]; [self.delegate modalDidUpdateFeedConfig:item]; } } diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeeds.m b/baRSS/Preferences/Feeds Tab/SettingsFeeds.m index ae546ad..5c04781 100644 --- a/baRSS/Preferences/Feeds Tab/SettingsFeeds.m +++ b/baRSS/Preferences/Feeds Tab/SettingsFeeds.m @@ -21,7 +21,6 @@ // SOFTWARE. #import "SettingsFeeds.h" -#import "AppHook.h" #import "BarMenu.h" #import "ModalSheet.h" #import "ModalFeedEdit.h" @@ -47,27 +46,16 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; [self.outlineView registerForDraggedTypes:[NSArray arrayWithObject:dragNodeType]]; [self.dataStore setSortDescriptors:[NSArray arrayWithObject:[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]]]; - NSManagedObjectContext *childContext = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType]; - [childContext setParentContext:[(AppHook*)NSApp persistentContainer].viewContext]; -// childContext.automaticallyMergesChangesFromParent = YES; - NSUndoManager *um = [[NSUndoManager alloc] init]; - um.groupsByEvent = NO; - um.levelsOfUndo = 30; - childContext.undoManager = um; + self.undoManager = [[NSUndoManager alloc] init]; + self.undoManager.groupsByEvent = NO; + self.undoManager.levelsOfUndo = 30; - self.dataStore.managedObjectContext = childContext; - self.undoManager = self.dataStore.managedObjectContext.undoManager; + self.dataStore.managedObjectContext = [StoreCoordinator createChildContext]; + self.dataStore.managedObjectContext.undoManager = self.undoManager; } -- (void)saveAndRebuildMenu { - [self.dataStore.managedObjectContext performBlock:^{ - [StoreCoordinator saveContext:self.dataStore.managedObjectContext]; - [[(AppHook*)NSApp barMenu] rebuildMenu]; // updating individual items was way to complicated ... - [self.dataStore.managedObjectContext.parentContext performBlock:^{ - [StoreCoordinator saveContext:self.dataStore.managedObjectContext.parentContext]; - }]; - - }]; +- (void)saveChanges { + [StoreCoordinator saveContext:self.dataStore.managedObjectContext andParent:YES]; } - (IBAction)addFeed:(id)sender { @@ -84,7 +72,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; sp.name = @"---"; sp.typ = SEPARATOR; [self.undoManager endUndoGrouping]; - [self saveAndRebuildMenu]; + [self saveChanges]; } - (IBAction)remove:(id)sender { @@ -93,7 +81,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; [self incrementIndicesBy:-1 forSubsequentNodes:path]; [self.dataStore remove:sender]; [self.undoManager endUndoGrouping]; - [self saveAndRebuildMenu]; + [self saveChanges]; } - (IBAction)doubleClickOutlineView:(NSOutlineView*)sender { @@ -139,7 +127,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; } - (void)modalDidUpdateFeedConfig:(FeedConfig*)config { - [self saveAndRebuildMenu]; + [self saveChanges]; // TODO: adjust total count } - (FeedConfig*)insertSortedItemAtSelection { @@ -178,7 +166,8 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; root = [root descendantNodeAtIndexPath:parentPath]; for (NSUInteger i = [path indexAtPosition:path.length - 1]; i < root.childNodes.count; i++) { - ((FeedConfig*)[root.childNodes[i] representedObject]).sortIndex += val; + FeedConfig *conf = [root.childNodes[i] representedObject]; + conf.sortIndex += val; } } @@ -197,7 +186,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; - (void)outlineView:(NSOutlineView *)outlineView draggingSession:(NSDraggingSession *)session endedAtPoint:(NSPoint)screenPoint operation:(NSDragOperation)operation { [self.undoManager endUndoGrouping]; if (self.dataStore.managedObjectContext.hasChanges) { - [self saveAndRebuildMenu]; + [self saveChanges]; } else { [self.undoManager disableUndoRegistration]; [self.undoManager undoNestedGroup]; @@ -228,6 +217,11 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; --updateIndex; } } + for (NSUInteger i = self.currentlyDraggedNodes.count; i > 0; i--) { // sorted that way to handle children first + FeedConfig *fc = [self.currentlyDraggedNodes[i - 1] representedObject]; + [fc.managedObjectContext refreshObject:fc mergeChanges:YES]; // make sure unreadCount is correct + [fc markUnread:-fc.unreadCount ancestorsOnly:YES]; + } // decrement sort indices at source for (NSTreeNode *node in self.currentlyDraggedNodes) @@ -243,6 +237,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; for (NSUInteger i = 0; i < self.currentlyDraggedNodes.count; i++) { FeedConfig *fc = [self.currentlyDraggedNodes[i] representedObject]; fc.sortIndex = (int32_t)(updateIndex + i); + [fc markUnread:fc.unreadCount ancestorsOnly:YES]; } return YES; } @@ -322,14 +317,16 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; - (void)undo:(id)sender { [self.undoManager undo]; + [StoreCoordinator restoreUnreadCount]; + [self saveChanges]; [self.dataStore rearrangeObjects]; // update ordering - [self saveAndRebuildMenu]; } - (void)redo:(id)sender { [self.undoManager redo]; + [StoreCoordinator restoreUnreadCount]; + [self saveChanges]; [self.dataStore rearrangeObjects]; // update ordering - [self saveAndRebuildMenu]; } - (void)enterPressed:(id)sender { diff --git a/baRSS/Preferences/General Tab/SettingsGeneral.m b/baRSS/Preferences/General Tab/SettingsGeneral.m index d832862..6bdec64 100644 --- a/baRSS/Preferences/General Tab/SettingsGeneral.m +++ b/baRSS/Preferences/General Tab/SettingsGeneral.m @@ -24,6 +24,7 @@ #import "AppHook.h" #import "BarMenu.h" #import "UserPrefs.h" +#import "StoreCoordinator.h" #import @@ -57,6 +58,15 @@ CFRelease(helperIdentifier); } +- (IBAction)fixCache:(NSButton *)sender { + [StoreCoordinator deleteUnreferencedFeeds]; + [StoreCoordinator restoreUnreadCount]; +} + +- (IBAction)changeMenuBarIconSetting:(NSButton*)sender { + [[(AppHook*)NSApp barMenu] updateBarIcon]; +} + - (IBAction)changeHttpApplication:(NSPopUpButton *)sender { [UserPrefs setHttpApplication:sender.selectedItem.representedObject]; } @@ -68,31 +78,6 @@ } } -// TODO: add self to login items - -- (IBAction)checkmarkClicked:(NSButton*)sender { - // TODO: Could be optimized by updating only the relevant parts - [[(AppHook*)NSApp barMenu] rebuildMenu]; -} - -- (IBAction)changeMenuBarIconSetting:(NSButton*)sender { - [[(AppHook*)NSApp barMenu] updateBarIcon]; -} - -- (IBAction)changeMenuHeaderSetting:(NSButton*)sender { - BOOL recursive = YES; - NSString *bindingKey = [[sender infoForBinding:@"value"] valueForKey:NSObservedKeyPathKey]; - if ([bindingKey containsString:@"values.global"]) { - recursive = NO; // item is in menu bar menu, no need to go recursive - } - [[(AppHook*)NSApp barMenu] updateMenuHeaders:recursive]; -} - -- (IBAction)changeMenuItemUpdateAll:(NSButton*)sender { - BOOL checked = (sender.state == NSControlStateValueOn); - [[(AppHook*)NSApp barMenu] setItemUpdateAllHidden:!checked]; -} - #pragma mark - Helper methods /** diff --git a/baRSS/Preferences/General Tab/SettingsGeneral.xib b/baRSS/Preferences/General Tab/SettingsGeneral.xib index dd9721b..ec93e5f 100644 --- a/baRSS/Preferences/General Tab/SettingsGeneral.xib +++ b/baRSS/Preferences/General Tab/SettingsGeneral.xib @@ -1,8 +1,8 @@ - + - + @@ -45,7 +45,6 @@ - @@ -62,7 +61,6 @@ - @@ -79,7 +77,6 @@ - @@ -96,7 +93,6 @@ - @@ -113,7 +109,6 @@ - @@ -130,7 +125,6 @@ - @@ -147,7 +141,6 @@ - @@ -164,7 +157,6 @@ - @@ -181,7 +173,6 @@ - @@ -198,7 +189,6 @@ - @@ -232,7 +222,6 @@ - @@ -249,7 +238,6 @@ - @@ -266,7 +254,6 @@ - @@ -446,6 +433,17 @@ + diff --git a/baRSS/Status Bar Menu/BarMenu.h b/baRSS/Status Bar Menu/BarMenu.h index ad1ff52..c0afecb 100644 --- a/baRSS/Status Bar Menu/BarMenu.h +++ b/baRSS/Status Bar Menu/BarMenu.h @@ -23,8 +23,5 @@ #import @interface BarMenu : NSObject -- (void)rebuildMenu; - (void)updateBarIcon; -- (void)updateMenuHeaders:(BOOL)recursive; -- (void)setItemUpdateAllHidden:(BOOL)hidden; @end diff --git a/baRSS/Status Bar Menu/BarMenu.m b/baRSS/Status Bar Menu/BarMenu.m index f7f2189..d944988 100644 --- a/baRSS/Status Bar Menu/BarMenu.m +++ b/baRSS/Status Bar Menu/BarMenu.m @@ -25,16 +25,20 @@ #import "FeedDownload.h" #import "DrawImage.h" #import "Preferences.h" -#import "NSMenuItem+Info.h" -#import "NSMenuItem+Generate.h" #import "UserPrefs.h" +#import "NSMenu+Ext.h" +#import "NSMenuItem+Ext.h" +#import "Feed+Ext.h" +#import "Constants.h" @interface BarMenu() @property (strong) NSStatusItem *barItem; @property (strong) Preferences *prefWindow; -@property (weak) NSMenu *mm; @property (assign) int unreadCountTotal; +@property (strong) NSArray *allFeeds; +@property (strong) NSArray *currentOpenMenu; +@property (strong) NSManagedObjectContext *readContext; @end @@ -44,9 +48,20 @@ self = [super init]; self.barItem = [NSStatusBar.systemStatusBar statusItemWithLength:NSVariableStatusItemLength]; self.barItem.highlightMode = YES; - [self rebuildMenu]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(networkChange:) name:@"baRSS-notification-network-status-change" object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedUpdated:) name:@"baRSS-notification-feed-updated" object:nil]; + self.barItem.menu = [NSMenu menuWithDelegate:self]; + + // Unread counter + self.unreadCountTotal = 0; + [self updateBarIcon]; + dispatch_async(dispatch_get_main_queue(), ^{ + self.unreadCountTotal = [StoreCoordinator totalNumberOfUnreadFeeds]; + [self updateBarIcon]; + }); + + // 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]; [FeedDownload registerNetworkChangeNotification]; [FeedDownload performSelectorInBackground:@selector(scheduleNextUpdate:) withObject:[NSNumber numberWithBool:NO]]; return self; @@ -57,46 +72,6 @@ [[NSNotificationCenter defaultCenter] removeObserver:self]; } -- (void)networkChange:(NSNotification*)notify { - BOOL available = [[notify object] boolValue]; - [self.barItem.menu itemWithTag:TagUpdateFeed].enabled = available; - [self updateBarIcon]; - // TODO: Disable 'update all' menu item? -} - -- (void)feedUpdated:(NSNotification*)notify { - FeedConfig *config = notify.object; - NSLog(@"%@", config.indexPath); - [self rebuildMenu]; -} - -- (void)rebuildMenu { - self.barItem.menu = [self generateMainMenu]; - [self updateBarIcon]; -} - -- (void)donothing { - dispatch_async(dispatch_get_main_queue(), ^{ - [self.mm itemAtIndex:4].title = [NSString stringWithFormat:@"%@", [NSDate date]]; - }); - sleep(1); - [self performSelectorInBackground:@selector(donothing) withObject:nil]; -} -// TODO: remove debugging stuff -- (void)printUnreadRecurisve:(NSMenu*)menu str:(NSString*)prefix { - for (NSMenuItem *item in menu.itemArray) { - if (![item hasReaderInfo]) continue; - id obj = [item requestCoreDataObject]; - if ([obj isKindOfClass:[FeedItem class]] && ([obj unread] > 0 || item.unreadCount > 0)) - NSLog(@"%@ %@ (%d == %d)", prefix, item.title, item.unreadCount, [obj unread]); - else if ([item hasUnread]) - NSLog(@"%@ %@ (%d)", prefix, item.title, item.unreadCount); - if (item.hasSubmenu) { - [self printUnreadRecurisve:item.submenu str:[NSString stringWithFormat:@" %@", prefix]]; - } - } -} - /** Update menu bar icon and text according to unread count and user preferences. */ @@ -117,170 +92,229 @@ self.barItem.image.template = YES; } }); -// NSLog(@"==> %d", self.unreadCountTotal); -// [self printUnreadRecurisve:self.barItem.menu str:@""]; } -#pragma mark - Menu Generator +#pragma mark - Notification callback methods - /** - Builds main menu with items on the very first menu level. Including Preferences, Quit, etc. + Callback method fired when network conditions change. + + @param notify Notification object contains a @c BOOL value indicating the current status. */ -- (NSMenu*)generateMainMenu { - NSMenu *menu = [NSMenu new]; - menu.autoenablesItems = NO; - [self addTitle:NSLocalizedString(@"Pause Updates", nil) selector:@selector(pauseUpdates:) toMenu:menu tag:TagPauseUpdates]; - NSMenuItem *updateAll = [self addTitle:NSLocalizedString(@"Update all feeds", nil) selector:@selector(updateAllFeeds:) toMenu:menu tag:TagUpdateFeed]; - if ([UserPrefs defaultYES:@"globalUpdateAll"] == NO) - updateAll.hidden = YES; - - [menu addItem:[NSMenuItem separatorItem]]; - [self defaultHeaderForMenu:menu scope:ScopeGlobal]; - - self.unreadCountTotal = 0; - @autoreleasepool { - for (FeedConfig *fc in [StoreCoordinator sortedFeedConfigItems]) { - [menu addItem:[self generateMenuItem:fc unread:&_unreadCountTotal]]; +- (void)networkChanged:(NSNotification*)notify { + BOOL available = [[notify object] boolValue]; + [self.barItem.menu itemWithTag:TagUpdateFeed].enabled = available; + [self updateBarIcon]; +} + +/** + Callback method fired when feeds have been updated and the total unread count needs update. + + @param notify Notification object contains the unread count difference to the current count. May be negative. + */ +- (void)unreadCountChanged:(NSNotification*)notify { + self.unreadCountTotal += [[notify object] intValue]; + [self updateBarIcon]; +} + +/** + Callback method fired when feeds have been updated in the background. + */ +- (void)feedUpdated:(NSNotification*)notify { + if (self.barItem.menu.numberOfItems > 0) { + // update items only if menu is already open (e.g., during background update) + [self.readContext refreshAllObjects]; // because self.allFeeds is the same context + [self recursiveUpdateMenu:self.barItem.menu withFeed:nil]; + } +} + +/** + Called recursively for all @c FeedConfig children. + If the projected submenu in @c menu does not exist, all subsequent children are skipped in @c FeedConfig. + The title and unread count is updated for all menu items. @c FeedItem menus are completely re-generated. + + @param config If @c nil the root object (@c self.allFeeds) is used. + */ +- (void)recursiveUpdateMenu:(NSMenu*)menu withFeed:(FeedConfig*)config { + if (config.feed.items.count > 0) { // deepest menu level, feed items + [menu removeAllItems]; + [self insertDefaultHeaderForAllMenus:menu scope:ScopeFeed hasUnread:(config.unreadCount > 0)]; + for (FeedItem *fi in config.feed.items) { + NSMenuItem *mi = [menu addItemWithTitle:@"" action:@selector(openFeedURL:) keyEquivalent:@""]; + mi.target = self; + [mi setFeedItem:fi]; + } + } else { + BOOL hasUnread = (config ? config.unreadCount > 0 : self.unreadCountTotal > 0); + NSInteger offset = [menu getFeedConfigOffsetAndUpdateUnread:hasUnread]; + for (FeedConfig *child in (config ? config.children : self.allFeeds)) { + NSMenuItem *item = [menu itemAtIndex:offset + child.sortIndex]; + [item setTitleAndUnreadCount:child]; + if (item.submenu.numberOfItems > 0) + [self recursiveUpdateMenu:[item submenu] withFeed:child]; } } - [self updateMenuHeaderEnabled:menu hasUnread:(self.unreadCountTotal > 0)]; +} + + +#pragma mark - Menu Delegate & Menu Generation - + + +// Get rid of everything that is not needed when the system bar menu isnt open. +- (void)menuDidClose:(NSMenu*)menu { + if ([menu isMainMenu]) { + self.allFeeds = nil; + [self.readContext reset]; + self.readContext = nil; + self.barItem.menu = [NSMenu menuWithDelegate:self]; + } +} + +// If main menu load inital set of items, then find item based on index path. +- (NSInteger)numberOfItemsInMenu:(NSMenu*)menu { + if ([menu isMainMenu]) { + [self.readContext reset]; // will be ignored if nil + self.readContext = [StoreCoordinator createChildContext]; + self.allFeeds = [StoreCoordinator sortedFeedConfigItemsInContext:self.readContext]; + self.currentOpenMenu = [self.allFeeds valueForKeyPath:@"objectID"]; + } else { + FeedConfig *conf = [self configAtIndexPathStr:menu.title]; + [self.readContext refreshObject:conf mergeChanges:YES]; + self.currentOpenMenu = [(conf.typ == FEED ? conf.feed.items : [conf sortedChildren]) valueForKeyPath:@"objectID"]; + } + return (NSInteger)[self.currentOpenMenu count]; +} + +/** + Find @c FeedConfig item in array @c self.allFeeds that is already loaded. + + @param indexString Path as string that is stored in @c NSMenu title + */ +- (FeedConfig*)configAtIndexPathStr:(NSString*)indexString { + NSArray *parts = [indexString componentsSeparatedByString:@"."]; + NSInteger firstIndex = [[parts objectAtIndex:1] integerValue]; + FeedConfig *changing = [self.allFeeds objectAtIndex:(NSUInteger)firstIndex]; + for (NSUInteger i = 2; i < parts.count; i++) { + NSInteger childIndex = [[parts objectAtIndex:i] integerValue]; + BOOL err = YES; + for (FeedConfig *c in changing.children) { + if (c.sortIndex == childIndex) { + err = NO; + changing = c; + break; // Exit early. Should be faster than sorted children method. + } + } + NSAssert(!err, @"ERROR configAtIndex: Shouldn't happen. Something wrong with indexing."); + } + return changing; +} + +// Lazy populate the system bar menus when needed. +- (BOOL)menu:(NSMenu*)menu updateItem:(NSMenuItem*)item atIndex:(NSInteger)index shouldCancel:(BOOL)shouldCancel { + NSManagedObjectID *moid = [self.currentOpenMenu objectAtIndex:(NSUInteger)index]; + id obj = [self.readContext objectWithID:moid]; + [self.readContext refreshObject:obj mergeChanges:YES]; + if ([obj isKindOfClass:[FeedConfig class]]) { + [item setFeedConfig:obj]; + if ([(FeedConfig*)obj typ] == FEED) { + item.target = self; + item.action = @selector(openFeedURL:); + } + } else if ([obj isKindOfClass:[FeedItem class]]) { + [item setFeedItem:obj]; + item.target = self; + item.action = @selector(openFeedURL:); + } + if (menu.numberOfItems == index + 1) { + int unreadCount = self.unreadCountTotal; // if parent == nil + if ([obj isKindOfClass:[FeedItem class]]) { + unreadCount = [[[(FeedItem*)obj feed] config] unreadCount]; + } else if ([(FeedConfig*)obj parent]) { + unreadCount = [[(FeedConfig*)obj parent] unreadCount]; + } + [self finalizeMenu:menu hasUnread:(unreadCount > 0)]; + self.currentOpenMenu = nil; + } + return YES; +} + +/** + Add default menu items that are present in each menu as header. + + @param flag If @c NO, 'Open all unread' and 'Mark all read' will be disabled. + */ +- (void)finalizeMenu:(NSMenu*)menu hasUnread:(BOOL)flag { + BOOL isMainMenu = [menu isMainMenu]; + MenuItemTag scope; + if (isMainMenu) scope = ScopeGlobal; + else if ([menu isFeedMenu]) scope = ScopeFeed; + else scope = ScopeGroup; + + [menu replaceSeparatorStringsWithActualSeparator]; + [self insertDefaultHeaderForAllMenus:menu scope:scope hasUnread:flag]; + if (isMainMenu) { + [self insertMainMenuHeader:menu]; + } +} + +/** + Insert items 'Open all unread', 'Mark all read' and 'Mark all unread' at index 0. + + @param flag If @c NO, 'Open all unread' and 'Mark all read' will be disabled. + */ +- (void)insertDefaultHeaderForAllMenus:(NSMenu*)menu scope:(MenuItemTag)scope hasUnread:(BOOL)flag { + NSMenuItem *item1 = [self itemTitle:NSLocalizedString(@"Open all unread", nil) selector:@selector(openAllUnread:) tag:TagOpenAllUnread | scope]; + NSMenuItem *item2 = [item1 alternateWithTitle:[NSString stringWithFormat:NSLocalizedString(@"Open a few unread (%d)", nil), 3]]; + NSMenuItem *item3 = [self itemTitle:NSLocalizedString(@"Mark all read", nil) selector:@selector(markAllReadOrUnread:) tag:TagMarkAllRead | scope]; + NSMenuItem *item4 = [self itemTitle:NSLocalizedString(@"Mark all unread", nil) selector:@selector(markAllReadOrUnread:) tag:TagMarkAllUnread | scope]; + item1.enabled = flag; + item2.enabled = flag; + item3.enabled = flag; + // TODO: disable item3 if all items are unread? + [menu insertItem:item1 atIndex:0]; + [menu insertItem:item2 atIndex:1]; + [menu insertItem:item3 atIndex:2]; + [menu insertItem:item4 atIndex:3]; + [menu insertItem:[NSMenuItem separatorItem] atIndex:4]; +} + +/** + Insert default menu items for the main menu only. Like 'Pause Updates', 'Update all feeds', 'Preferences' and 'Quit'. + */ +- (void)insertMainMenuHeader:(NSMenu*)menu { + NSMenuItem *item1 = [self itemTitle:NSLocalizedString(@"Pause Updates", nil) selector:@selector(pauseUpdates:) tag:TagPauseUpdates]; + NSMenuItem *item2 = [self itemTitle:NSLocalizedString(@"Update all feeds", nil) selector:@selector(updateAllFeeds:) tag:TagUpdateFeed]; + if ([UserPrefs defaultYES:@"globalUpdateAll"] == NO) + item2.hidden = YES; + if (![FeedDownload isNetworkReachable]) + item2.enabled = NO; + [menu insertItem:item1 atIndex:0]; + [menu insertItem:item2 atIndex:1]; + [menu insertItem:[NSMenuItem separatorItem] atIndex:2]; + // < feed content > [menu addItem:[NSMenuItem separatorItem]]; - - NSMenuItem *prefs = [self addTitle:NSLocalizedString(@"Preferences", nil) selector:@selector(openPreferences) toMenu:menu tag:TagPreferences]; + NSMenuItem *prefs = [self itemTitle:NSLocalizedString(@"Preferences", nil) selector:@selector(openPreferences) tag:TagPreferences]; prefs.keyEquivalent = @","; + [menu addItem:prefs]; [menu addItemWithTitle:NSLocalizedString(@"Quit", nil) action:@selector(terminate:) keyEquivalent:@"q"]; - return menu; } /** - Generate menu item with all its sub-menus. @c FeedConfig type is evaluated automatically. - - @param unread Pointer to an unread count. Will be incremented while traversing through sub-menus. + Helper method to generate a new @c NSMenuItem. */ -- (NSMenuItem*)generateMenuItem:(FeedConfig*)config unread:(int*)unread { - NSMenuItem *item = [NSMenuItem feedConfig:config]; - int count = 0; - if (item.tag == ScopeFeed) { - count += [self setSubmenuForFeedScope:item config:config]; - } else if (item.tag == ScopeGroup) { - [self setSubmenuForGroupScope:item config:config unread:&count]; - } else { // Separator item - return item; - } - *unread += count; - [item markReadAndUpdateTitle:-count]; - [self updateMenuHeaderEnabled:item.submenu hasUnread:(count > 0)]; - return item; -} - -/** - Set subitems for a @c FeedConfig group item. Namely various @c FeedConfig and @c FeedItem items. - - @param item The item where the menu will be appended. - @param config A @c FeedConfig group item. - @param unread Pointer to an unread count. Will be incremented while traversing through sub-menus. - */ -- (void)setSubmenuForGroupScope:(NSMenuItem*)item config:(FeedConfig*)config unread:(int*)unread { - item.submenu = [self defaultHeaderForMenu:nil scope:ScopeGroup]; - for (FeedConfig *obj in config.sortedChildren) { - [item.submenu addItem: [self generateMenuItem:obj unread:unread]]; - } -} - -/** - Set subitems for a @c FeedConfig feed item. Namely its @c FeedItem items. - - @param item The item where the menu will be appended. - @param config For which item the menu should be generated. Attribute @c feed should be populated. - @return Unread count for feed. - */ -- (int)setSubmenuForFeedScope:(NSMenuItem*)item config:(FeedConfig*)config { - item.submenu = [self defaultHeaderForMenu:nil scope:ScopeFeed]; - int count = 0; - for (FeedItem *obj in config.feed.items) { - if (obj.unread) ++count; - [item.submenu addItem:[[NSMenuItem feedItem:obj] setAction:@selector(openFeedURL:) target:self]]; - } - [item setAction:@selector(openFeedURL:) target:self]; - return count; -} - -/** - Helper function to insert a menu item with @c target @c = @c self - */ -- (NSMenuItem*)addTitle:(NSString*)title selector:(SEL)selector toMenu:(NSMenu*)menu tag:(MenuItemTag)tag { +- (NSMenuItem*)itemTitle:(NSString*)title selector:(SEL)selector tag:(MenuItemTag)tag { NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:title action:selector keyEquivalent:@""]; item.target = self; item.tag = tag; [item applyUserSettingsDisplay]; - [menu addItem:item]; return item; } -#pragma mark - Default Menu Header Items - - -/** - Append header items to menu accoring to user preferences. - - @note If @c menu is @c nil a new menu is created and returned. - @param menu The menu where the items should be appended. - @param scope Tag will be concatenated with that scope (Global, Group or Local). - @return Will return the menu item provided or create a new one if menu was @c nil. - */ -- (NSMenu*)defaultHeaderForMenu:(NSMenu*)menu scope:(MenuItemTag)scope { - if (!menu) { - menu = [NSMenu new]; - menu.autoenablesItems = NO; - } - - NSMenuItem *item = [self addTitle:NSLocalizedString(@"Open all unread", nil) selector:@selector(openAllUnread:) toMenu:menu tag:TagOpenAllUnread | scope]; - [menu addItem:[item alternateWithTitle:[NSString stringWithFormat:NSLocalizedString(@"Open a few unread (%d)", nil), 3]]]; - [self addTitle:NSLocalizedString(@"Mark all read", nil) selector:@selector(markAllRead:) toMenu:menu tag:TagMarkAllRead | scope]; - [self addTitle:NSLocalizedString(@"Mark all unread", nil) selector:@selector(markAllUnread:) toMenu:menu tag:TagMarkAllUnread | scope]; - - [menu addItem:[NSMenuItem separatorItem]]; - return menu; -} - -- (void)setItemUpdateAllHidden:(BOOL)hidden { - [self.barItem.menu itemWithTag:TagUpdateFeed].hidden = hidden; -} - -- (void)updateMenuHeaders:(BOOL)recursive { - [self updateMenuHeaderHidden:self.barItem.menu recursive:recursive]; -} - -- (void)updateMenuHeaderHidden:(NSMenu*)menu recursive:(BOOL)flag { - for (NSMenuItem *item in menu.itemArray) { - [item applyUserSettingsDisplay]; - if (flag && item.hasSubmenu) { - [self updateMenuHeaderHidden:item.submenu recursive:YES]; - } - } -} - -- (void)updateMenuHeaderEnabled:(NSMenu*)menu hasUnread:(BOOL)flag { - int stopAfter = 4; // 3 (+1 alternate) - for (NSMenuItem *item in menu.itemArray) { - switch (item.tag & TagMaskType) { - case TagMarkAllRead: item.enabled = flag; break; - case TagMarkAllUnread: item.enabled = !flag; break; - case TagOpenAllUnread: item.enabled = flag; break; - default: continue; // wrong tag, ignore - } - --stopAfter; - if (stopAfter < 0) - break; // break early after all header items have been processed - } -} - - -#pragma mark - Menu Actions +#pragma mark - Menu Actions - /** @@ -297,117 +331,100 @@ [self.prefWindow showWindow:nil]; } +/** + Callback method after user closes the preferences window. + */ - (void)preferencesClosed:(id)sender { [[NSNotificationCenter defaultCenter] removeObserver:self name:NSWindowWillCloseNotification object:self.prefWindow.window]; self.prefWindow = nil; } - +/** + Called when user clicks on 'Pause Updates' in the main menu (only). + */ - (void)pauseUpdates:(NSMenuItem*)sender { NSLog(@"1pause"); } +/** + Called when user clicks on 'Update all feeds' in the main menu (only). + */ - (void)updateAllFeeds:(NSMenuItem*)sender { // TODO: Disable 'update all' menu item during update? [FeedDownload scheduleNextUpdate:YES]; } /** - Combined selector for menu action. - - @note @c sender.tag includes @c ScopeLocal, @c ScopeGroup @b or @c ScopeGlobal. - @param sender @c NSMenuItem that was clicked during the action (e.g., "open all unread") + Called when user clicks on 'Open all unread' or 'Open a few unread ...' on any scope level. */ - (void)openAllUnread:(NSMenuItem*)sender { - int maxItemCount = INT_MAX; + NSMutableArray *urls = [NSMutableArray array]; + __block int maxItemCount = INT_MAX; if (sender.isAlternate) maxItemCount = 3; // TODO: read from preferences - __block int stopAfter = maxItemCount; - NSMutableArray *urls = [NSMutableArray array]; - [self siblingsDescendantFeedConfigs:sender block:^BOOL(FeedConfig *parent, FeedItem *item) { - if (stopAfter <= 0) - return NO; // stop further processing - if (item.unread && item.link.length > 0) { - [urls addObject:[NSURL URLWithString:item.link]]; - item.unread = NO; - --stopAfter; + NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; + [sender iterateSorted:YES inContext:moc overDescendentFeeds:^(Feed *feed, BOOL *cancel) { + int itemSum = 0; + for (FeedItem *i in feed.items) { + if (itemSum >= maxItemCount) { + break; + } + if (i.unread && i.link.length > 0) { + [urls addObject:[NSURL URLWithString:i.link]]; + i.unread = NO; + ++itemSum; + } } - return YES; + if (itemSum > 0) { + [feed.config markUnread:-itemSum ancestorsOnly:NO]; + maxItemCount -= itemSum; + } + *cancel = (maxItemCount <= 0); }]; - stopAfter = maxItemCount; - int total = [sender siblingsDescendantItemInfo:^int(NSMenuItem *item, int count) { - if (item.tag & ScopeFeed) { - if (stopAfter <= 0) return -1; - --stopAfter; - } - [item markReadAndUpdateTitle:count]; - return count; - } unreadEntriesOnly:YES]; - [self updateAcestors:sender markRead:total]; [self openURLsWithPreferredBrowser:urls]; + [StoreCoordinator saveContext:moc andParent:YES]; + [moc reset]; } /** - Combined selector for menu action. - - @note @c sender.tag includes @c ScopeLocal, @c ScopeGroup @b or @c ScopeGlobal. - @param sender @c NSMenuItem that was clicked during the action (e.g., "mark all read") + Called when user clicks on 'Mark all read' @b or 'Mark all unread' on any scope level. */ -- (void)markAllRead:(NSMenuItem*)sender { - [self siblingsDescendantFeedConfigs:sender block:^BOOL(FeedConfig *parent, FeedItem *item) { - if (item.unread) - item.unread = NO; - return YES; +- (void)markAllReadOrUnread:(NSMenuItem*)sender { + BOOL markRead = ((sender.tag & TagMaskType) == TagMarkAllRead); + NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; + [sender iterateSorted:NO inContext:moc overDescendentFeeds:^(Feed *feed, BOOL *cancel) { + if (markRead) [feed markAllItemsRead]; + else [feed markAllItemsUnread]; }]; - int total = [sender siblingsDescendantItemInfo:^int(NSMenuItem *item, int count) { - [item markReadAndUpdateTitle:count]; - return count; - } unreadEntriesOnly:YES]; - [self updateAcestors:sender markRead:total]; + [StoreCoordinator saveContext:moc andParent:YES]; + [moc reset]; } /** - Combined selector for menu action. - - @note @c sender.tag includes @c ScopeLocal, @c ScopeGroup @b or @c ScopeGlobal. - @param sender @c NSMenuItem that was clicked during the action (e.g., "mark all unread") - */ -- (void)markAllUnread:(NSMenuItem*)sender { - [self siblingsDescendantFeedConfigs:sender block:^BOOL(FeedConfig *parent, FeedItem *item) { - if (item.unread == NO) - item.unread = YES; - return YES; - }]; - int total = [sender siblingsDescendantItemInfo:^int(NSMenuItem *item, int count) { - if (count > item.unreadCount) - [item markReadAndUpdateTitle:(item.unreadCount - count)]; - return count; - } unreadEntriesOnly:NO]; - [self updateAcestors:sender markRead:([self getAncestorUnreadCount:sender] - total)]; -} + Called when user clicks on a single feed item or the feed group. -/** - Called when user clicks on a single feed item or the superior feed. - - @param sender A menu item containing either a @c FeedItem or a @c FeedConfig. + @param sender A menu item containing either a @c FeedItem or a @c FeedConfig objectID. */ - (void)openFeedURL:(NSMenuItem*)sender { - if (!sender.hasReaderInfo) + NSManagedObjectID *oid = sender.representedObject; + if (!oid) return; NSString *url = nil; - id obj = [sender requestCoreDataObject]; + 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 *feed = obj; url = [feed link]; - if ([sender hasUnread]) { + if (feed.unread) { feed.unread = NO; - [sender markReadAndUpdateTitle:1]; - [self updateAcestors:sender markRead:1]; + [feed.feed.config markUnread:-1 ancestorsOnly:NO]; + [StoreCoordinator saveContext:moc andParent:YES]; } } + [moc reset]; if (!url || url.length == 0) return; [self openURLsWithPreferredBrowser:@[[NSURL URLWithString:url]]]; } @@ -422,58 +439,4 @@ [[NSWorkspace sharedWorkspace] openURLs:urls withAppBundleIdentifier:[UserPrefs getHttpApplication] options:NSWorkspaceLaunchDefault additionalEventParamDescriptor:nil launchIdentifiers:nil]; } - -#pragma mark - Iterating over items and propagating unread count - - -/** - Iterate over all feed items from siblings and contained children. - - @param sender @c NSMenuItem that was clicked during the action (e.g., "open all unread") - @param block Iterate over all FeedItems on the deepest layer. - */ -- (void)siblingsDescendantFeedConfigs:(NSMenuItem*)sender block:(FeedConfigRecursiveItemsBlock)block { - if (sender.parentItem) { - FeedConfig *obj = [sender.parentItem requestCoreDataObject]; - if ([obj isKindOfClass:[FeedConfig class]]) // important: this could be a FeedItem - [obj descendantFeedItems:block]; - } else { - // Sadly we can't just fetch the list of FeedItems since it is not ordered (in case open 10 at a time) - @autoreleasepool { - for (FeedConfig *config in [StoreCoordinator sortedFeedConfigItems]) { - if ([config descendantFeedItems:block] == NO) - break; - } - } - } -} - -/** - Recursively update all parent's unread count and total unread count. - - @param sender Current menu item, parent will be called recursively on this element. - @param count The amount by which the unread count is adjusted. If negative, items will be marked as unread. - */ -- (void)updateAcestors:(NSMenuItem*)sender markRead:(int)count { - [sender markAncestorsRead:count]; - self.unreadCountTotal -= count; - if (self.unreadCountTotal < 0) { - NSLog(@"Should never happen. Global unread count < 0"); - self.unreadCountTotal = 0; - } - [self updateBarIcon]; -} - -/** - Get unread count from the parent menu item. If there is none, get the total unread count - - @param sender Current menu item, parent will be called on this element. - @return Unread count for parent element (total count if parent is @c nil) - */ -- (int)getAncestorUnreadCount:(NSMenuItem*)sender { - if ([sender.parentItem hasReaderInfo]) - return [sender.parentItem unreadCount]; - return self.unreadCountTotal; -} - @end diff --git a/baRSS/Status Bar Menu/NSMenuItem+Generate.h b/baRSS/Status Bar Menu/NSMenu+Ext.h similarity index 79% rename from baRSS/Status Bar Menu/NSMenuItem+Generate.h rename to baRSS/Status Bar Menu/NSMenu+Ext.h index fcac4d6..bbccaa8 100644 --- a/baRSS/Status Bar Menu/NSMenuItem+Generate.h +++ b/baRSS/Status Bar Menu/NSMenu+Ext.h @@ -22,12 +22,11 @@ #import -@class FeedConfig, FeedItem; - -@interface NSMenuItem (Generate) -+ (NSMenuItem*)feedConfig:(FeedConfig*)config; -+ (NSMenuItem*)feedItem:(FeedItem*)item; -- (NSMenuItem*)alternateWithTitle:(NSString*)title; - -- (NSMenuItem*)setAction:(nullable SEL)action target:(nullable id)target; +@interface NSMenu (Ext) ++ (instancetype)menuWithDelegate:(id)target; +- (instancetype)submenuWithIndex:(int)index isFeed:(BOOL)flag; +- (void)replaceSeparatorStringsWithActualSeparator; +- (BOOL)isMainMenu; +- (BOOL)isFeedMenu; +- (NSInteger)getFeedConfigOffsetAndUpdateUnread:(BOOL)hasUnread; @end diff --git a/baRSS/Status Bar Menu/NSMenu+Ext.m b/baRSS/Status Bar Menu/NSMenu+Ext.m new file mode 100644 index 0000000..41cdb10 --- /dev/null +++ b/baRSS/Status Bar Menu/NSMenu+Ext.m @@ -0,0 +1,101 @@ +// +// 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 "NSMenu+Ext.h" +#import "NSMenuItem+Ext.h" + +@implementation NSMenu (Ext) + ++ (instancetype)menuWithDelegate:(id)target { + NSMenu *menu = [[NSMenu alloc] initWithTitle:@"M"]; + menu.autoenablesItems = NO; + menu.delegate = target; + return menu; +} + +- (instancetype)submenuWithIndex:(int)index isFeed:(BOOL)flag { + NSMenu *menu = [NSMenu new]; + menu.title = [NSString stringWithFormat:@"%c%@.%d", (flag ? 'F' : 'G'), self.title, index]; + menu.autoenablesItems = NO; + menu.delegate = self.delegate; + return menu; +} + +- (void)replaceSeparatorStringsWithActualSeparator { + for (NSInteger i = 0; i < self.numberOfItems; i++) { + NSMenuItem *oldItem = [self itemAtIndex:i]; + if ([oldItem.title isEqualToString:@"---SEPARATOR---"]) { + NSMenuItem *newItem = [NSMenuItem separatorItem]; + newItem.representedObject = oldItem.representedObject; + [self removeItemAtIndex:i]; + [self insertItem:newItem atIndex:i]; + } + } +} + +- (BOOL)isMainMenu { + return [self.title isEqualToString:@"M"]; +} + +- (BOOL)isFeedMenu { + return [self.title characterAtIndex:0] == 'F'; +} + +//- (void)iterateMenuItems:(void(^)(NSMenuItem*,BOOL))block atIndexPath:(NSIndexPath*)path { +// NSMenu *m = self; +// for (NSUInteger u = 0; u < path.length; u++) { +// NSUInteger i = [path indexAtPosition:u]; +// for (NSMenuItem *item in m.itemArray) { +// if (![item.representedObject isKindOfClass:[NSManagedObjectID class]]) { +// continue; // not a core data item +// } +// if (i == 0) { +// BOOL isFinalItem = (u == path.length - 1); +// block(item, isFinalItem); +// if (isFinalItem) return; // item found! +// m = item.submenu; +// break; // cancel evaluation of remaining items +// } +// i -= 1; +// } +// } +// return; // whenever a menu inbetween is nil (e.g., wasn't set yet) +//} + +- (NSInteger)getFeedConfigOffsetAndUpdateUnread:(BOOL)hasUnread { + for (NSInteger i = 0; i < self.numberOfItems; i++) { + NSMenuItem *item = [self itemAtIndex:i]; + if ([item.representedObject isKindOfClass:[NSManagedObjectID class]]) { + return i; + } else { + //[item applyUserSettingsDisplay]; // should not change while menu is open + switch (item.tag & TagMaskType) { + case TagOpenAllUnread: case TagMarkAllRead: + item.enabled = hasUnread; + default: break; + } + } + } + return 0; +} + +@end diff --git a/baRSS/Status Bar Menu/NSMenuItem+Info.h b/baRSS/Status Bar Menu/NSMenuItem+Ext.h similarity index 68% rename from baRSS/Status Bar Menu/NSMenuItem+Info.h rename to baRSS/Status Bar Menu/NSMenuItem+Ext.h index f2570ad..86b08c2 100644 --- a/baRSS/Status Bar Menu/NSMenuItem+Info.h +++ b/baRSS/Status Bar Menu/NSMenuItem+Ext.h @@ -42,26 +42,14 @@ typedef NS_OPTIONS(NSInteger, MenuItemTag) { TagMaskType = 0xFFF0, }; +@class FeedConfig, Feed, FeedItem; -@interface NSMenuItem (Info) -/** - Iteration block for descendants of @c NSMenuItem. - - @param count The number of sub-elements contained in that @c NSMenuItem. 1 for @c FeedItems at the deepest layer. - Otherwise the number of (updated) descendants. - @return Return how many elements are updated in this block execution. If none were changed return @c 0. - If execution should be stopped early, return @c -1. - */ -typedef int (^ReaderInfoRecursiveBlock) (NSMenuItem *item, int count); +@interface NSMenuItem (Feed) +- (NSMenuItem*)alternateWithTitle:(NSString*)title; -- (BOOL)hasUnread; -- (int)unreadCount; -- (BOOL)hasReaderInfo; -- (void)setReaderInfo:(NSManagedObjectID*)oid unread:(int)count; -- (id)requestCoreDataObject; +- (void)setFeedConfig:(FeedConfig*)config; +- (void)setFeedItem:(FeedItem*)item; +- (void)setTitleAndUnreadCount:(FeedConfig*)config; +- (void)iterateSorted:(BOOL)ordered inContext:(NSManagedObjectContext*)moc overDescendentFeeds:(void(^)(Feed *feed, BOOL *cancel))block; - (void)applyUserSettingsDisplay; -- (void)markReadAndUpdateTitle:(int)count; -- (void)countInTitle:(BOOL)show; -- (void)markAncestorsRead:(int)count; -- (int)siblingsDescendantItemInfo:(ReaderInfoRecursiveBlock)block unreadEntriesOnly:(BOOL)flag; @end diff --git a/baRSS/Status Bar Menu/NSMenuItem+Ext.m b/baRSS/Status Bar Menu/NSMenuItem+Ext.m new file mode 100644 index 0000000..5c7e02e --- /dev/null +++ b/baRSS/Status Bar Menu/NSMenuItem+Ext.m @@ -0,0 +1,217 @@ +// +// 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 "NSMenuItem+Ext.h" +#import "NSMenu+Ext.h" +#import "StoreCoordinator.h" +#import "DrawImage.h" +#import "UserPrefs.h" + +/// User preferences for displaying menu items +typedef NS_ENUM(char, DisplaySetting) { + /// User preference not available. @c NSMenuItem is not configurable (not a header item) + INVALID, + /// User preference to display this item + ALLOW, + /// User preference to hide this item + PROHIBIT +}; + + +@implementation NSMenuItem (Feed) + +/** + Create a copy of an existing menu item and set it's option key modifier. + */ +- (NSMenuItem*)alternateWithTitle:(NSString*)title { + NSMenuItem *alt = [self copy]; + alt.title = title; + alt.keyEquivalentModifierMask = NSEventModifierFlagOption; + if (!alt.hidden) { // hidden will be ignored if alternate is YES + alt.hidden = YES; // force hidden to hide if menu is already open (background update) + alt.alternate = YES; + } + return alt; +} + +/** + Set title based on preferences either with or without unread count in parenthesis. + */ +- (void)setTitleAndUnreadCount:(FeedConfig*)config { + if (config.unreadCount > 0 && + ((config.typ == FEED && [UserPrefs defaultYES:@"feedUnreadCount"]) || + (config.typ == GROUP && [UserPrefs defaultYES:@"groupUnreadCount"]))) + { + self.title = [NSString stringWithFormat:@"%@ (%d)", config.name, config.unreadCount]; + } else { + self.title = config.name; + } +} + +/** + Fully configures a Separator item OR group item OR feed item. (but not @c FeedItem item) + */ +- (void)setFeedConfig:(FeedConfig*)config { + self.representedObject = config.objectID; + if (config.typ == SEPARATOR) { + self.title = @"---SEPARATOR---"; + } else { + [self setTitleAndUnreadCount:config]; + self.submenu = [self.menu submenuWithIndex:config.sortIndex isFeed:(config.typ == FEED)]; + if (config.typ == FEED) { + [self configureAsFeed:config]; + } else { + [self configureAsGroup:config]; + } + } +} + +/** + Configure menu item to be used as a container for @c FeedItem entries (incl. feed icon). + */ +- (void)configureAsFeed:(FeedConfig*)config { + self.tag = ScopeFeed; + self.toolTip = config.feed.subtitle; + self.enabled = (config.feed.items.count > 0); + // set icon + dispatch_async(dispatch_get_main_queue(), ^{ + static NSImage *defaultRSSIcon; + if (!defaultRSSIcon) + defaultRSSIcon = [RSSIcon iconWithSize:16]; + self.image = defaultRSSIcon; + }); +} + +/** + Configure menu item to be used as a container for multiple feeds. + */ +- (void)configureAsGroup:(FeedConfig*)config { + self.tag = ScopeGroup; + self.enabled = (config.children.count > 0); + // set icon + dispatch_async(dispatch_get_main_queue(), ^{ + static NSImage *groupIcon; + if (!groupIcon) { + groupIcon = [NSImage imageNamed:NSImageNameFolder]; + groupIcon.size = NSMakeSize(16, 16); + } + self.image = groupIcon; + }); +} + +/** + Populate @c NSMenuItem based on the attributes of a @c FeedItem. + */ +- (void)setFeedItem:(FeedItem*)item { + self.title = item.title; + self.tag = ScopeFeed; + self.enabled = (item.link.length > 0); + self.state = (item.unread ? NSControlStateValueOn : NSControlStateValueOff); + self.representedObject = item.objectID; + //mi.toolTip = item.abstract; + // TODO: Do regex during save, not during display. Its here for testing purposes ... + if (item.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:@""]; + } +} + +#pragma mark - Helper - + +/** + @return @c FeedConfig object if @c representedObject contains a valid @c NSManagedObjectID. + */ +- (FeedConfig*)feedConfig:(NSManagedObjectContext*)moc { + if (!self.representedObject || ![self.representedObject isKindOfClass:[NSManagedObjectID class]]) + return nil; + FeedConfig *config = [moc objectWithID:self.representedObject]; + if (![config isKindOfClass:[FeedConfig class]]) + return nil; + return config; +} + +/** + Perform @c block on every @c FeedConfig 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 feedConfig:moc] iterateSorted:ordered overDescendantFeeds:block]; + } else { + for (NSMenuItem *item in self.menu.itemArray) { + FeedConfig *fc = [item feedConfig:moc]; + if (fc != nil) { // All groups and feeds; Ignore default header + if (![fc iterateSorted:ordered overDescendantFeeds:block]) + return; + } + } + } +} + +/** + Check user preferences for preferred display style. + + @return As per user settings return @c ALLOW or @c PROHIBIT. Will return @c INVALID for items that aren't configurable. + */ +- (DisplaySetting)allowsDisplay { + NSString *prefix; + switch (self.tag & TagMaskScope) { + case ScopeFeed: prefix = @"feed"; break; + case ScopeGroup: prefix = @"group"; break; + case ScopeGlobal: prefix = @"global"; break; + default: return INVALID; // no scope, not recognized menu item + } + NSString *postfix; + switch (self.tag & TagMaskType) { + case TagOpenAllUnread: postfix = @"OpenUnread"; break; + case TagMarkAllRead: postfix = @"MarkRead"; break; + case TagMarkAllUnread: postfix = @"MarkUnread"; break; + default: return INVALID; // wrong tag, ignore + } + + if ([UserPrefs defaultYES:[prefix stringByAppendingString:postfix]]) + return ALLOW; + return PROHIBIT; +} + +/** + Set item @c hidden based on user preferences. Does nothing for items that aren't configurable in settings. + */ +- (void)applyUserSettingsDisplay { + switch ([self allowsDisplay]) { + case ALLOW: + self.hidden = NO; + if (self.keyEquivalentModifierMask == NSEventModifierFlagOption) + self.alternate = YES; // restore alternate flag + break; + case PROHIBIT: + if (self.isAlternate) + self.alternate = NO; // to allow hidden = YES, alternate flag needs to be NO + self.hidden = YES; + break; + case INVALID: break; + } +} + +@end diff --git a/baRSS/Status Bar Menu/NSMenuItem+Generate.m b/baRSS/Status Bar Menu/NSMenuItem+Generate.m deleted file mode 100644 index 15ccb5c..0000000 --- a/baRSS/Status Bar Menu/NSMenuItem+Generate.m +++ /dev/null @@ -1,127 +0,0 @@ -// -// 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 "NSMenuItem+Generate.h" -#import "NSMenuItem+Info.h" -#import "StoreCoordinator.h" -#import "DrawImage.h" - -@implementation NSMenuItem (Feed) -/** - Generate a new @c NSMenuItem based on the type stored in @c FeedConfig. - - @param config @c FeedConfig object that represents a superior feed element. - @return Return a fully configured Separator item OR group item OR feed item. (but not @c FeedItem item) - */ -+ (NSMenuItem*)feedConfig:(FeedConfig*)config { - NSMenuItem *item; - switch (config.typ) { - case SEPARATOR: item = [NSMenuItem separatorItem]; break; - case GROUP: item = [self feedConfigItemGroup:config]; break; - case FEED: item = [self feedConfigItemFeed:config]; break; - } - [item setReaderInfo:config.objectID unread:0]; - return item; -} - -/** - Generate a new @c NSMenuItem from a @c FeedConfig feed item. - - @param config @c FeedConfig object that represents a superior feed element. - */ -+ (NSMenuItem*)feedConfigItemFeed:(FeedConfig*)config { - NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:config.name action:nil keyEquivalent:@""]; - item.toolTip = config.feed.subtitle; - item.enabled = (config.feed.items.count > 0); - item.tag = ScopeFeed; - // set icon - dispatch_async(dispatch_get_main_queue(), ^{ - static NSImage *defaultRSSIcon; - if (!defaultRSSIcon) - defaultRSSIcon = [RSSIcon iconWithSize:16]; - item.image = defaultRSSIcon; - }); - return item; -} - -/** - Generate a new @c NSMenuItem from a @c FeedConfig group item - - @param config @c FeedConfig object that represents a group item. - */ -+ (NSMenuItem*)feedConfigItemGroup:(FeedConfig*)config { - NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:config.name action:nil keyEquivalent:@""]; - item.tag = ScopeGroup; - // set icon - dispatch_async(dispatch_get_main_queue(), ^{ - static NSImage *groupIcon; - if (!groupIcon) { - groupIcon = [NSImage imageNamed:NSImageNameFolder]; - groupIcon.size = NSMakeSize(16, 16); - } - item.image = groupIcon; - }); - return item; -} - -/** - Generate new @c NSMenuItem based on the attributes of a @c FeedItem. - */ -+ (NSMenuItem*)feedItem:(FeedItem*)item { - NSMenuItem *mi = [[NSMenuItem alloc] initWithTitle:item.title action:nil keyEquivalent:@""]; - [mi setReaderInfo:item.objectID unread:(item.unread ? 1 : 0)]; - //mi.toolTip = item.abstract; - // TODO: Do regex during save, not during display. Its here for testing purposes ... - if (item.abstract.length > 0) { - NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"<[^>]*>" options:kNilOptions error:nil]; - mi.toolTip = [regex stringByReplacingMatchesInString:item.abstract options:kNilOptions range:NSMakeRange(0, item.abstract.length) withTemplate:@""]; - } - mi.enabled = (item.link.length > 0); - mi.state = (item.unread ? NSControlStateValueOn : NSControlStateValueOff); - mi.tag = ScopeFeed; - return mi; -} - -/** - Create a copy of an existing menu item and set it's option key modifier. - */ -- (NSMenuItem*)alternateWithTitle:(NSString*)title { - NSMenuItem *alt = [self copy]; - alt.title = title; - alt.keyEquivalentModifierMask = NSEventModifierFlagOption; - if (!alt.hidden) // hidden will be ignored if alternate is YES - alt.alternate = YES; - return alt; -} - -/** - Set @c action and @c target attributes. - - @return Return @c self instance. Intended for method chains. - */ -- (NSMenuItem*)setAction:(SEL)action target:(id)target { - self.action = action; - self.target = target; - return self; -} - -@end diff --git a/baRSS/Status Bar Menu/NSMenuItem+Info.m b/baRSS/Status Bar Menu/NSMenuItem+Info.m deleted file mode 100644 index 5cb01a8..0000000 --- a/baRSS/Status Bar Menu/NSMenuItem+Info.m +++ /dev/null @@ -1,275 +0,0 @@ -// -// 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 "NSMenuItem+Info.h" -#import "UserPrefs.h" -#import "StoreCoordinator.h" - -/// User preferences for displaying menu items -typedef NS_ENUM(char, DisplaySetting) { - /// User preference not available. @c NSMenuItem is not configurable (not a header item) - INVALID, - /// User preference to display this item - ALLOW, - /// User preference to hide this item - PROHIBIT -}; - -@interface ReaderInfo : NSObject -@property (strong) NSManagedObjectID *objID; -/// internal counter used to sum the unread count of all sub items -@property (assign) int unreadCount; -/// internal flag whether unread count is displayed in parenthesis -@property (assign) BOOL countInTitle; -@end - -@implementation ReaderInfo -/// set: unreadCount -= count -- (void)markRead:(int)count { - if (count > self.unreadCount) { - NSLog(@"should never happen, trying to set an unread count below zero"); - self.unreadCount = 0; - } else { - self.unreadCount -= count; - } -} -@end - - -// ################################################################ -// # -// # NSMenuItem ReaderInfo Extension -// # -// ################################################################ - -@implementation NSMenuItem (Info) -/** Call represented object and check whether unread count > 0. */ -- (BOOL)hasUnread { - return [(ReaderInfo*)self.representedObject unreadCount] > 0; -} - -/** Call represented object and retrieve the unread count from info. */ -- (int)unreadCount { - return [(ReaderInfo*)self.representedObject unreadCount]; -} - -/** Return @c YES if @c ReaderInfo is stored in @c representedObject. */ -- (BOOL)hasReaderInfo { - return [self.representedObject isKindOfClass:[ReaderInfo class]]; -} - -/** - Save represented core data object in @c ReaderInfo. - - @param oid Represented core data object id. - @param count Unread count for item. - */ -- (void)setReaderInfo:(NSManagedObjectID*)oid unread:(int)count { - ReaderInfo *info = [ReaderInfo new]; - info.objID = oid; - info.unreadCount = count; - self.representedObject = info; -} - -/** - Return represented core data object. Return @c nil if @c ReaderInfo is missing. - */ -- (id)requestCoreDataObject { - if (![self hasReaderInfo]) - return nil; - return [StoreCoordinator objectWithID: [(ReaderInfo*)self.representedObject objID]]; -} - -/** - Check user preferences for preferred display style. - - @return As per user settings return @c ALLOW or @c PROHIBIT. Will return @c INVALID for items that aren't configurable. - */ -- (DisplaySetting)allowsDisplay { - NSString *prefix; - switch (self.tag & TagMaskScope) { - case ScopeFeed: prefix = @"feed"; break; - case ScopeGroup: prefix = @"group"; break; - case ScopeGlobal: prefix = @"global"; break; - default: return INVALID; // no scope, not recognized menu item - } - NSString *postfix; - switch (self.tag & TagMaskType) { - case TagOpenAllUnread: postfix = @"OpenUnread"; break; - case TagMarkAllRead: postfix = @"MarkRead"; break; - case TagMarkAllUnread: postfix = @"MarkUnread"; break; - default: return INVALID; // wrong tag, ignore - } - - if ([UserPrefs defaultYES:[prefix stringByAppendingString:postfix]]) - return ALLOW; - return PROHIBIT; -} - -/** - Set item @c hidden based on user preferences. Does nothing for items that aren't configurable in settings. - */ -- (void)applyUserSettingsDisplay { - switch ([self allowsDisplay]) { - case ALLOW: - self.hidden = NO; - if (self.keyEquivalentModifierMask == NSEventModifierFlagOption) - self.alternate = YES; // restore alternate flag - break; - case PROHIBIT: - if (self.isAlternate) - self.alternate = NO; // to allow hidden = YES, alternate flag needs to be NO - self.hidden = YES; - break; - case INVALID: break; - } -} - -/** - Update internal unread counter and append unread count to title. - - @note Count may be negative to mark items as unread. - @warning Does not check if @c representedObject is set accordingly - @param count The amount by which the counter is adjusted. - If negative the items will be marked as unread. - */ -- (void)markReadAndUpdateTitle:(int)count { - if (count == 0) return; // 0 won't change anything - ReaderInfo *info = self.representedObject; - if (!self.hasSubmenu) { - [info markRead:count]; - self.state = ([self hasUnread] ? NSControlStateValueOn : NSControlStateValueOff); - } else { - int countBefore = info.unreadCount; - [info markRead:count]; - if (info.countInTitle) { - [self removeUnreadCountFromTitle:countBefore]; - info.countInTitle = NO; - } - [self addUnreadCountToTitle]; - } -} - -/** - Update title without changing internal unread count. Save to call multiple times. - - @param show Whether to show or hide count - */ -- (void)countInTitle:(BOOL)show { - ReaderInfo *info = self.representedObject; - NSLog(@"%@", info); - return; - if (!show && info.countInTitle) { - [self removeUnreadCountFromTitle: info.unreadCount]; - info.countInTitle = NO; - } else if (show && !info.countInTitle) { - [self addUnreadCountToTitle]; - } -} - -/** - Update title after unread count has changed - - @param countBefore The count before the update - */ -- (void)removeUnreadCountFromTitle:(int)countBefore { - int digitsBefore = (int)log10f(countBefore) + 1; - NSInteger index = (NSInteger)self.title.length - digitsBefore - 3; // " (%d)" - if (index < 0) index = 0; - self.title = [self.title substringToIndex:(NSUInteger)index]; // remove old count -} - -/** - Append count in parenthesis if thats allowed for the current scope (user settings) - */ -- (void)addUnreadCountToTitle { - ReaderInfo *info = self.representedObject; - if (info.unreadCount > 0 && - (((self.tag & ScopeGroup) && [UserPrefs defaultYES:@"groupUnreadCount"]) || - ((self.tag & ScopeFeed) && [UserPrefs defaultYES:@"feedUnreadCount"]))) - { - self.title = [self.title stringByAppendingFormat:@" (%d)", info.unreadCount]; - info.countInTitle = YES; - } -} - -/** - Recursively propagate unread count to ancestor menu items. - - @note Does not update the current item, only the ancestors. - @param count The amount by which the counter is adjusted. - If negative the items will be marked as unread. - */ -- (void)markAncestorsRead:(int)count { - NSMenuItem *parent = self.parentItem; - while (parent.representedObject) { - [parent markReadAndUpdateTitle:count]; - parent = parent.parentItem; - } -} - -/** - Recursively iterate over submenues and children. Count aggregated element edits. - - @warning Block will be called for parent items, too. Consider this when using counters. - @param block Will be called for each @c NSMenuItem sub-element where @c representedObject is set to a @c ReaderInfo. - Return -1 to stop processing early. - @param flag If set to @c YES, recursive calls will be skipped for submenus that contain soleily read elements. - @return The number of changed elements in total. - */ -- (int)descendantItemInfo:(ReaderInfoRecursiveBlock)block unreadEntriesOnly:(BOOL)flag { - if (self.isSeparatorItem) return 0; - if (![self hasReaderInfo]) return 0; - if (flag && ![self hasUnread]) return 0; - - int countItems = 1; // deepest entry, FeedItem - if (self.hasSubmenu) { - countItems = 0; - for (NSMenuItem *child in self.submenu.itemArray) { - int c = [child descendantItemInfo:block unreadEntriesOnly:flag]; - if (c < 0) break; - countItems += c; - } - } - return block(self, countItems); -} - -/** - Recursively iterate over siblings and all contained children. Count aggregated element edits. - - @warning Block will be called for parent items, too. Consider this when using counters. - @param block Will be called for each @c NSMenuItem sub-element where @c representedObject is set to a @c ReaderInfo. - Return -1 to stop processing early. - @param flag If set to @c YES, recursive calls will be skipped for submenus that contain soleily read elements. - @return The number of changed elements in total. - */ -- (int)siblingsDescendantItemInfo:(ReaderInfoRecursiveBlock)block unreadEntriesOnly:(BOOL)flag { - int markedTotal = 0; - for (NSMenuItem *sibling in self.menu.itemArray) { - int marked = [sibling descendantItemInfo:block unreadEntriesOnly:flag]; - if (marked < 0) break; - markedTotal += marked; - } - return markedTotal; -} - -@end diff --git a/baRSS/StoreCoordinator.h b/baRSS/StoreCoordinator.h index 77632fb..1071ede 100644 --- a/baRSS/StoreCoordinator.h +++ b/baRSS/StoreCoordinator.h @@ -27,11 +27,14 @@ @class RSParsedFeed; @interface StoreCoordinator : NSObject -+ (void)saveContext:(NSManagedObjectContext*)context; -+ (void)deleteUnreferencedFeeds; -+ (NSArray*)sortedFeedConfigItems; -+ (NSArray*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll; ++ (NSManagedObjectContext*)getMainContext; ++ (NSManagedObjectContext*)createChildContext; ++ (void)saveContext:(NSManagedObjectContext*)context andParent:(BOOL)flag; ++ (NSArray*)sortedFeedConfigItemsInContext:(nonnull NSManagedObjectContext*)context; ++ (NSArray*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc; + (NSDate*)nextScheduledUpdate; -+ (id)objectWithID:(NSManagedObjectID*)objID; -+ (void)overwriteConfig:(FeedConfig*)config withFeed:(RSParsedFeed*)obj; ++ (int)totalNumberOfUnreadFeeds; +// Restore sound state ++ (void)deleteUnreferencedFeeds; ++ (void)restoreUnreadCount; @end diff --git a/baRSS/StoreCoordinator.m b/baRSS/StoreCoordinator.m index 364075b..b485c0d 100644 --- a/baRSS/StoreCoordinator.m +++ b/baRSS/StoreCoordinator.m @@ -26,11 +26,19 @@ @implementation StoreCoordinator -+ (NSManagedObjectContext*)getContext { ++ (NSManagedObjectContext*)getMainContext { return [(AppHook*)NSApp persistentContainer].viewContext; } -+ (void)saveContext:(NSManagedObjectContext*)context { ++ (NSManagedObjectContext*)createChildContext { + NSManagedObjectContext *context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType]; + [context setParentContext:[self getMainContext]]; + context.undoManager = nil; + //context.automaticallyMergesChangesFromParent = YES; + return context; +} + ++ (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]) { NSLog(@"%@:%@ unable to commit editing before saving", [self class], NSStringFromSelector(_cmd)); @@ -40,31 +48,22 @@ // Customize this code block to include application-specific recovery steps. [[NSApplication sharedApplication] presentError:error]; } + if (flag && context.parentContext) { + [self saveContext:context.parentContext andParent:flag]; + } } -+ (void)deleteUnreferencedFeeds { - NSManagedObjectContext *moc = [self getContext]; - NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName:Feed.entity.name]; - fr.predicate = [NSPredicate predicateWithFormat:@"config = NULL"]; - NSBatchDeleteRequest *bdr = [[NSBatchDeleteRequest alloc] initWithFetchRequest:fr]; - NSError *err; - [moc executeRequest:bdr error:&err]; - if (err) NSLog(@"%@", err); -} - -+ (NSArray*)sortedFeedConfigItems { - NSManagedObjectContext *moc = [self getContext]; ++ (NSArray*)sortedFeedConfigItemsInContext:(NSManagedObjectContext*)context { NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedConfig.entity.name]; fr.predicate = [NSPredicate predicateWithFormat:@"parent = NULL"]; // %@", parent fr.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]]; NSError *err; - NSArray *result = [moc executeFetchRequest:fr error:&err]; + NSArray *result = [context executeFetchRequest:fr error:&err]; if (err) NSLog(@"%@", err); return result; } -+ (NSArray*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll { - NSManagedObjectContext *moc = [self getContext]; ++ (NSArray*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc { NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedConfig.entity.name]; if (!forceAll) { fr.predicate = [NSPredicate predicateWithFormat:@"type = %d AND scheduled <= %@", FEED, [NSDate date]]; @@ -78,7 +77,10 @@ } + (NSDate*)nextScheduledUpdate { - NSExpression *exp = [NSExpression expressionForFunction:@"min:" arguments:@[[NSExpression expressionForKeyPath:@"scheduled"]]]; + // Always get context first, or 'FeedConfig.entity.name' may not be available on app start + NSManagedObjectContext *moc = [self getMainContext]; + NSExpression *exp = [NSExpression expressionForFunction:@"min:" + arguments:@[[NSExpression expressionForKeyPath:@"scheduled"]]]; NSExpressionDescription *expDesc = [[NSExpressionDescription alloc] init]; [expDesc setName:@"earliestDate"]; [expDesc setExpression:exp]; @@ -90,65 +92,79 @@ [fr setPropertiesToFetch:@[expDesc]]; NSError *err; - NSArray *fetchResults = [[self getContext] executeFetchRequest:fr error:&err]; + NSArray *fetchResults = [moc executeFetchRequest:fr error:&err]; if (err) NSLog(@"%@", err); - return [fetchResults firstObject][@"earliestDate"]; // can be nil + return fetchResults.firstObject[@"earliestDate"]; // can be nil } -+ (id)objectWithID:(NSManagedObjectID*)objID { - return [[self getContext] objectWithID:objID]; ++ (int)totalNumberOfUnreadFeeds { + // Always get context first, or 'FeedConfig.entity.name' may not be available on app start + NSManagedObjectContext *moc = [self getMainContext]; + NSExpression *exp = [NSExpression expressionForFunction:@"sum:" + arguments:@[[NSExpression expressionForKeyPath:@"unreadCount"]]]; + NSExpressionDescription *expDesc = [[NSExpressionDescription alloc] init]; + [expDesc setName:@"totalUnread"]; + [expDesc setExpression:exp]; + [expDesc setExpressionResultType:NSInteger32AttributeType]; + + NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedConfig.entity.name]; + fr.predicate = [NSPredicate predicateWithFormat:@"type = %d", FEED]; + [fr setResultType:NSDictionaryResultType]; + [fr setPropertiesToFetch:@[expDesc]]; + + NSError *err; + NSArray *fetchResults = [moc executeFetchRequest:fr error:&err]; + if (err) NSLog(@"%@", err); + return [fetchResults.firstObject[@"totalUnread"] intValue]; } +//+ (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]]; +//} -+ (void)overwriteConfig:(FeedConfig*)config withFeed:(RSParsedFeed*)obj { - NSArray *readURLs = [self alreadyReadURLsInFeed:config.feed]; - [config.managedObjectContext performBlockAndWait:^{ - if (config.feed) - [config.managedObjectContext deleteObject:(NSManagedObject*)config.feed]; - if (obj) { - config.feed = [StoreCoordinator createFeedFrom:obj inContext:config.managedObjectContext alreadyRead:readURLs]; +#pragma mark - Restore Sound State - + ++ (void)deleteUnreferencedFeeds { + NSManagedObjectContext *moc = [self getMainContext]; + NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName:Feed.entity.name]; + fr.predicate = [NSPredicate predicateWithFormat:@"config = NULL"]; + NSBatchDeleteRequest *bdr = [[NSBatchDeleteRequest alloc] initWithFetchRequest:fr]; + NSError *err; + [moc executeRequest:bdr error:&err]; + if (err) NSLog(@"%@", err); +} + ++ (void)restoreUnreadCount { + NSManagedObjectContext *moc = [self getMainContext]; + NSError *err; + NSArray *confs = [moc executeFetchRequest:[NSFetchRequest fetchRequestWithEntityName: FeedConfig.entity.name] error:&err]; + if (err) NSLog(@"%@", err); + NSArray *feeds = [moc executeFetchRequest:[NSFetchRequest fetchRequestWithEntityName: Feed.entity.name] error:&err]; + if (err) NSLog(@"%@", err); + [moc performBlock:^{ + for (FeedConfig *conf in confs) { + conf.unreadCount = 0; + } + for (Feed *feed in feeds) { + int count = 0; + for (FeedItem *item in feed.items) { + if (item.unread) ++count; + } + FeedConfig *parent = feed.config; + while (parent) { + parent.unreadCount += count; + parent = parent.parent; + } } }]; } -#pragma mark - Helper methods - - -+ (FeedItem*)createFeedItemFrom:(RSParsedArticle*)entry inContext:(NSManagedObjectContext*)context { - FeedItem *b = [[FeedItem alloc] initWithEntity:FeedItem.entity insertIntoManagedObjectContext:context]; - 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; - return b; -} - -+ (Feed*)createFeedFrom:(RSParsedFeed*)obj inContext:(NSManagedObjectContext*)context alreadyRead:(NSArray*)urls { - Feed *a = [[Feed alloc] initWithEntity:Feed.entity insertIntoManagedObjectContext:context]; - a.title = obj.title; - a.subtitle = obj.subtitle; - a.link = obj.link; - for (RSParsedArticle *article in obj.articles) { - FeedItem *b = [self createFeedItemFrom:article inContext:context]; - if ([urls containsObject:b.link]) { - b.unread = NO; - } - [a addItemsObject:b]; - } - return a; -} - -+ (NSArray*)alreadyReadURLsInFeed:(Feed*)local { - if (!local || !local.items) return nil; - NSMutableArray *mArr = [NSMutableArray arrayWithCapacity:local.items.count]; - for (FeedItem *f in local.items) { - if (!f.unread) { - [mArr addObject:f.link]; - } - } - return mArr; -} - @end