diff --git a/baRSS.xcodeproj/project.pbxproj b/baRSS.xcodeproj/project.pbxproj index 4a0b45c..98459f1 100644 --- a/baRSS.xcodeproj/project.pbxproj +++ b/baRSS.xcodeproj/project.pbxproj @@ -11,7 +11,6 @@ 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 */; }; - 543695D8214F1F2700DA979D /* NSMenuItem+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 543695D7214F1F2700DA979D /* NSMenuItem+Ext.m */; }; 544936FB21F1E66100DEE9AA /* Statistics.m in Sources */ = {isa = PBXBuildFile; fileRef = 544936FA21F1E66100DEE9AA /* Statistics.m */; }; 544B011A2114B41200386E5C /* ModalSheet.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B01192114B41200386E5C /* ModalSheet.m */; }; 544B011D2114EE9100386E5C /* AppHook.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B011C2114EE9100386E5C /* AppHook.m */; }; @@ -25,10 +24,14 @@ 546FC4472118A8E6007CC3A3 /* Preferences.xib in Resources */ = {isa = PBXBuildFile; fileRef = 546FC4462118A8E6007CC3A3 /* Preferences.xib */; }; 5477D34E21233C62002BA27F /* FeedGroup+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 5477D34D21233C62002BA27F /* FeedGroup+Ext.m */; }; 5496B511214D6275003ED4ED /* UserPrefs.m in Sources */ = {isa = PBXBuildFile; fileRef = 5496B510214D6275003ED4ED /* UserPrefs.m */; }; + 54A07A7F220E04CF00082C51 /* NSFetchRequest+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54A07A7E220E04CF00082C51 /* NSFetchRequest+Ext.m */; }; + 54A07A82220E723D00082C51 /* MapUnreadTotal.m in Sources */ = {isa = PBXBuildFile; fileRef = 54A07A81220E723D00082C51 /* MapUnreadTotal.m */; }; 54ACC28621061B3C0020715F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 54ACC28521061B3C0020715F /* Assets.xcassets */; }; 54ACC28C21061B3C0020715F /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC28B21061B3C0020715F /* main.m */; }; 54ACC29521061E270020715F /* FeedDownload.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC29421061E270020715F /* FeedDownload.m */; }; 54ACC29821061FBA0020715F /* Preferences.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC29721061FBA0020715F /* Preferences.m */; }; + 54B749DA2204A85C0022CC6D /* BarStatusItem.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B749D92204A85C0022CC6D /* BarStatusItem.m */; }; + 54B749E0220636200022CC6D /* FeedArticle+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B749DF220635CD0022CC6D /* FeedArticle+Ext.m */; }; 54BB048921FD2AB500C303A5 /* NSDate+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54BB048821FD2AB500C303A5 /* NSDate+Ext.m */; }; 54CC04382162532A00A48795 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 54CC04372162532A00A48795 /* main.m */; }; 54CC043E2162566900A48795 /* baRSS-Helper.app in CopyFiles */ = {isa = PBXBuildFile; fileRef = 54CC042C2162532800A48795 /* baRSS-Helper.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -86,8 +89,6 @@ 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 = ""; }; - 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 = ""; }; 544936F921F1E66100DEE9AA /* Statistics.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Statistics.h; sourceTree = ""; }; 544936FA21F1E66100DEE9AA /* Statistics.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Statistics.m; sourceTree = ""; }; 544B01182114B41200386E5C /* ModalSheet.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ModalSheet.h; sourceTree = ""; }; @@ -107,6 +108,10 @@ 5477D34D21233C62002BA27F /* FeedGroup+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "FeedGroup+Ext.m"; sourceTree = ""; }; 5496B50F214D6275003ED4ED /* UserPrefs.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UserPrefs.h; sourceTree = ""; }; 5496B510214D6275003ED4ED /* UserPrefs.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UserPrefs.m; sourceTree = ""; }; + 54A07A7D220E04CF00082C51 /* NSFetchRequest+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSFetchRequest+Ext.h"; sourceTree = ""; }; + 54A07A7E220E04CF00082C51 /* NSFetchRequest+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSFetchRequest+Ext.m"; sourceTree = ""; }; + 54A07A80220E723D00082C51 /* MapUnreadTotal.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MapUnreadTotal.h; sourceTree = ""; }; + 54A07A81220E723D00082C51 /* MapUnreadTotal.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MapUnreadTotal.m; sourceTree = ""; }; 54ACC27C21061B3B0020715F /* baRSS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = baRSS.app; sourceTree = BUILT_PRODUCTS_DIR; }; 54ACC28321061B3B0020715F /* DBv1.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = DBv1.xcdatamodel; sourceTree = ""; }; 54ACC28521061B3C0020715F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -116,6 +121,10 @@ 54ACC29421061E270020715F /* FeedDownload.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FeedDownload.m; sourceTree = ""; }; 54ACC29621061FBA0020715F /* Preferences.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Preferences.h; sourceTree = ""; }; 54ACC29721061FBA0020715F /* Preferences.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = Preferences.m; sourceTree = ""; }; + 54B749D82204A85C0022CC6D /* BarStatusItem.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BarStatusItem.h; sourceTree = ""; }; + 54B749D92204A85C0022CC6D /* BarStatusItem.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BarStatusItem.m; sourceTree = ""; }; + 54B749DE220635BE0022CC6D /* FeedArticle+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FeedArticle+Ext.h"; sourceTree = ""; }; + 54B749DF220635CD0022CC6D /* FeedArticle+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "FeedArticle+Ext.m"; sourceTree = ""; }; 54BB048721FD2AB500C303A5 /* NSDate+Ext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSDate+Ext.h"; sourceTree = ""; }; 54BB048821FD2AB500C303A5 /* NSDate+Ext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSDate+Ext.m"; sourceTree = ""; }; 54CC042C2162532800A48795 /* baRSS-Helper.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "baRSS-Helper.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -153,28 +162,17 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 54195880218A05E700581B79 /* Categories */ = { - isa = PBXGroup; - children = ( - 5477D34C21233C62002BA27F /* FeedGroup+Ext.h */, - 5477D34D21233C62002BA27F /* FeedGroup+Ext.m */, - 54195881218A061100581B79 /* Feed+Ext.h */, - 54195882218A061100581B79 /* Feed+Ext.m */, - 540F704321B6C16C0022E69D /* FeedMeta+Ext.h */, - 540F704421B6C16C0022E69D /* FeedMeta+Ext.m */, - ); - path = Categories; - sourceTree = ""; - }; 541A90EF21257D4F002680A6 /* Status Bar Menu */ = { isa = PBXGroup; children = ( - 543695D6214F1F2700DA979D /* NSMenuItem+Ext.h */, - 543695D7214F1F2700DA979D /* NSMenuItem+Ext.m */, - 54195884218E1BDB00581B79 /* NSMenu+Ext.h */, - 54195885218E1BDB00581B79 /* NSMenu+Ext.m */, + 54B749D82204A85C0022CC6D /* BarStatusItem.h */, + 54B749D92204A85C0022CC6D /* BarStatusItem.m */, 54FE73D1212316CD003EAC65 /* BarMenu.h */, 54FE73D2212316CD003EAC65 /* BarMenu.m */, + 54A07A80220E723D00082C51 /* MapUnreadTotal.h */, + 54A07A81220E723D00082C51 /* MapUnreadTotal.m */, + 54195884218E1BDB00581B79 /* NSMenu+Ext.h */, + 54195885218E1BDB00581B79 /* NSMenu+Ext.m */, ); path = "Status Bar Menu"; sourceTree = ""; @@ -184,6 +182,8 @@ children = ( 54209E922117325100F3B5EF /* DrawImage.h */, 54209E932117325100F3B5EF /* DrawImage.m */, + 54ACC29321061E270020715F /* FeedDownload.h */, + 54ACC29421061E270020715F /* FeedDownload.m */, 544936F921F1E66100DEE9AA /* Statistics.h */, 544936FA21F1E66100DEE9AA /* Statistics.m */, 54BB048721FD2AB500C303A5 /* NSDate+Ext.h */, @@ -228,6 +228,25 @@ path = Preferences; sourceTree = ""; }; + 54A07A8322105E0800082C51 /* Core Data */ = { + isa = PBXGroup; + children = ( + 54A07A7D220E04CF00082C51 /* NSFetchRequest+Ext.h */, + 54A07A7E220E04CF00082C51 /* NSFetchRequest+Ext.m */, + 54FE73CE21220DEC003EAC65 /* StoreCoordinator.h */, + 54FE73CF21220DEC003EAC65 /* StoreCoordinator.m */, + 54195881218A061100581B79 /* Feed+Ext.h */, + 54195882218A061100581B79 /* Feed+Ext.m */, + 5477D34C21233C62002BA27F /* FeedGroup+Ext.h */, + 5477D34D21233C62002BA27F /* FeedGroup+Ext.m */, + 540F704321B6C16C0022E69D /* FeedMeta+Ext.h */, + 540F704421B6C16C0022E69D /* FeedMeta+Ext.m */, + 54B749DE220635BE0022CC6D /* FeedArticle+Ext.h */, + 54B749DF220635CD0022CC6D /* FeedArticle+Ext.m */, + ); + path = "Core Data"; + sourceTree = ""; + }; 54ACC27321061B3B0020715F = { isa = PBXGroup; children = ( @@ -255,12 +274,8 @@ 544B011C2114EE9100386E5C /* AppHook.m */, 541958872190FF1200581B79 /* Constants.h */, 541A90EF21257D4F002680A6 /* Status Bar Menu */, - 54FE73CE21220DEC003EAC65 /* StoreCoordinator.h */, - 54FE73CF21220DEC003EAC65 /* StoreCoordinator.m */, - 54ACC29321061E270020715F /* FeedDownload.h */, - 54ACC29421061E270020715F /* FeedDownload.m */, + 54A07A8322105E0800082C51 /* Core Data */, 544936F721F1E51E00DEE9AA /* Helper */, - 54195880218A05E700581B79 /* Categories */, 546FC44D2118B357007CC3A3 /* Preferences */, 54ACC28521061B3C0020715F /* Assets.xcassets */, 54ACC28A21061B3C0020715F /* Info.plist */, @@ -411,7 +426,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 543695D8214F1F2700DA979D /* NSMenuItem+Ext.m in Sources */, + 54B749E0220636200022CC6D /* FeedArticle+Ext.m in Sources */, 54F39C2E210BE1F700AEE730 /* DBv1.xcdatamodeld in Sources */, 544B011D2114EE9100386E5C /* AppHook.m in Sources */, 546FC44321189975007CC3A3 /* SettingsGeneral.m in Sources */, @@ -423,15 +438,18 @@ 54ACC28C21061B3C0020715F /* main.m in Sources */, 54FE73D3212316CD003EAC65 /* BarMenu.m in Sources */, 544B011A2114B41200386E5C /* ModalSheet.m in Sources */, + 54A07A82220E723D00082C51 /* MapUnreadTotal.m in Sources */, 54ACC29821061FBA0020715F /* Preferences.m in Sources */, 54195886218E1BDB00581B79 /* NSMenu+Ext.m in Sources */, 54F6025D21C1D4170006D338 /* OpmlExport.m in Sources */, 5496B511214D6275003ED4ED /* UserPrefs.m in Sources */, + 54B749DA2204A85C0022CC6D /* BarStatusItem.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 */, + 54A07A7F220E04CF00082C51 /* NSFetchRequest+Ext.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/baRSS/AppHook.h b/baRSS/AppHook.h index d3bb5d0..3ccf731 100644 --- a/baRSS/AppHook.h +++ b/baRSS/AppHook.h @@ -23,9 +23,11 @@ #import #import -@class BarMenu; +@class BarStatusItem; @interface AppHook : NSApplication -@property (readonly, strong) BarMenu *barMenu; +@property (readonly, strong) BarStatusItem *statusItem; @property (readonly, strong) NSPersistentContainer *persistentContainer; + +- (void)openPreferences; @end diff --git a/baRSS/AppHook.m b/baRSS/AppHook.m index 2a71ed1..ea37e4d 100644 --- a/baRSS/AppHook.m +++ b/baRSS/AppHook.m @@ -21,8 +21,13 @@ // SOFTWARE. #import "AppHook.h" -#import "BarMenu.h" +#import "BarStatusItem.h" #import "FeedDownload.h" +#import "Preferences.h" + +@interface AppHook() +@property (strong) Preferences *prefWindow; +@end @implementation AppHook @@ -33,7 +38,7 @@ } - (void)applicationWillFinishLaunching:(NSNotification *)notification { - _barMenu = [BarMenu new]; + _statusItem = [BarStatusItem new]; NSAppleEventManager *appleEventManager = [NSAppleEventManager sharedAppleEventManager]; [appleEventManager setEventHandler:self andSelector:@selector(handleGetURLEvent:withReplyEvent:) forEventClass:kInternetEventClass andEventID:kAEGetURL]; @@ -60,6 +65,29 @@ } +#pragma mark - Handle Menu Interaction + + +/// Called whenever the user activates the preferences (either through menu click or hotkey). +- (void)openPreferences { + if (!self.prefWindow) { + self.prefWindow = [[Preferences alloc] initWithWindowNibName:@"Preferences"]; + self.prefWindow.window.title = [NSString stringWithFormat:@"%@ %@", NSProcessInfo.processInfo.processName, + NSLocalizedString(@"Preferences", nil)]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(preferencesClosed:) name:NSWindowWillCloseNotification object:self.prefWindow.window]; + } + [NSApp activateIgnoringOtherApps:YES]; + [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; + [FeedDownload scheduleUpdateForUpcomingFeeds]; +} + + #pragma mark - Core Data stack diff --git a/baRSS/Categories/Feed+Ext.h b/baRSS/Core Data/Feed+Ext.h similarity index 95% rename from baRSS/Categories/Feed+Ext.h rename to baRSS/Core Data/Feed+Ext.h index 378a877..e548d82 100644 --- a/baRSS/Categories/Feed+Ext.h +++ b/baRSS/Core Data/Feed+Ext.h @@ -21,6 +21,7 @@ // SOFTWARE. #import "Feed+CoreDataClass.h" +#import @class RSParsedFeed; @@ -28,13 +29,11 @@ // Generator methods / Feed update + (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context; + (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc; -- (void)calculateAndSetIndexPathString; -- (void)calculateAndSetUnreadCount; - (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag; +- (void)calculateAndSetIndexPathString; +- (NSMenuItem*)newMenuItem; // Article properties - (NSArray*)sortedArticles; -- (int)markAllItemsRead; -- (int)markAllItemsUnread; // Icon - (NSImage*)iconImage16; - (BOOL)setIconImage:(NSImage*)img; diff --git a/baRSS/Categories/Feed+Ext.m b/baRSS/Core Data/Feed+Ext.m similarity index 71% rename from baRSS/Categories/Feed+Ext.m rename to baRSS/Core Data/Feed+Ext.m index 2ef1b82..cba06b4 100644 --- a/baRSS/Categories/Feed+Ext.m +++ b/baRSS/Core Data/Feed+Ext.m @@ -22,11 +22,11 @@ #import "Feed+Ext.h" #import "Constants.h" +#import "UserPrefs.h" #import "DrawImage.h" #import "FeedMeta+Ext.h" #import "FeedGroup+Ext.h" -#import "FeedIcon+CoreDataClass.h" -#import "FeedArticle+CoreDataClass.h" +#import "FeedArticle+Ext.h" #import "StoreCoordinator.h" #import @@ -42,7 +42,7 @@ /// Instantiates new @c FeedGroup with @c FEED type, set the update interval to @c 30min and @c sortIndex to last root index. + (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc { - NSInteger lastIndex = [StoreCoordinator numberRootItemsInContext:moc]; + NSUInteger lastIndex = [StoreCoordinator countRootItemsInContext:moc]; FeedGroup *fg = [FeedGroup newGroup:FEED inContext:moc]; [fg setParent:nil andSortIndex:(int32_t)lastIndex]; [fg.feed.meta setRefreshAndSchedule:kDefaultFeedRefreshInterval]; @@ -56,11 +56,24 @@ self.indexPath = pthStr; } -/// Reset attributes @c unreadCount by counting number of articles. @note Remember to update global unread count. -- (void)calculateAndSetUnreadCount { - int32_t unreadCount = (int32_t)[[self.articles valueForKeyPath:@"@sum.unread"] integerValue]; - if (self.unreadCount != unreadCount) - self.unreadCount = unreadCount; +/// @return Fully initialized @c NSMenuItem with @c title, @c tooltip, @c image, and @c action. +- (NSMenuItem*)newMenuItem { + NSMenuItem *item = [NSMenuItem new]; + item.title = self.group.nameOrError; + item.toolTip = self.subtitle; + item.enabled = (self.articles.count > 0); + item.image = [self iconImage16]; + item.representedObject = self.indexPath; + item.target = [self class]; + item.action = @selector(didClickOnMenuItem:); + return item; +} + +/// Callback method for @c NSMenuItem. Will open url associated with @c Feed. ++ (void)didClickOnMenuItem:(NSMenuItem*)sender { + NSString *url = [StoreCoordinator urlForFeedWithIndexPath:sender.representedObject]; + if (url && url.length > 0) + [UserPrefs openURLsWithPreferredBrowser:@[[NSURL URLWithString:url]]]; } @@ -78,16 +91,13 @@ if (self.group.name.length == 0) // in case a blank group was initialized self.group.name = obj.title; - int32_t unreadBefore = self.unreadCount; // Add and remove articles NSMutableSet *urls = [[self.articles valueForKeyPath:@"link"] mutableCopy]; - [self addMissingArticles:obj updateLinks:urls]; // will remove links in 'urls' that should be kept - [self deleteArticlesWithLink:urls]; // remove old, outdated articles + NSInteger diff = [self addMissingArticles:obj updateLinks:urls]; // will remove links in 'urls' that should be kept + diff -= [self deleteArticlesWithLink:urls]; // remove old, outdated articles // Get new total article count and post unread-count-change notification - if (flag) { - int32_t cDiff = self.unreadCount - unreadBefore; - if (cDiff != 0) - [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged object:@(cDiff)]; + if (flag && diff != 0) { + [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged object:@(diff)]; } } @@ -100,8 +110,8 @@ @param urls Input will be used to identify new articles. Output will contain URLs that aren't present in the feed anymore. */ -- (void)addMissingArticles:(RSParsedFeed*)obj updateLinks:(NSMutableSet*)urls { - int32_t newOnes = 0; +- (NSInteger)addMissingArticles:(RSParsedFeed*)obj updateLinks:(NSMutableSet*)urls { + NSInteger newOnes = 0; int32_t currentIndex = [[self.articles valueForKeyPath:@"@min.sortIndex"] intValue]; FeedArticle *lastInserted = nil; BOOL hasGapBetweenNewArticles = NO; @@ -122,7 +132,9 @@ newOnes -= 1; } hasGapBetweenNewArticles = NO; - lastInserted = [self insertArticle:article atIndex:currentIndex]; + lastInserted = [FeedArticle newArticle:article inContext:self.managedObjectContext]; + lastInserted.sortIndex = currentIndex; + [self addArticlesObject:lastInserted]; } currentIndex += 1; } @@ -130,41 +142,20 @@ lastInserted.unread = NO; newOnes -= 1; } - if (newOnes > 0) - self.unreadCount += newOnes; // new articles are by definition unread -} - -/** - Create article based on input and insert into core data storage. - */ -- (FeedArticle*)insertArticle:(RSParsedArticle*)entry atIndex:(int32_t)idx { - FeedArticle *fa = [[FeedArticle alloc] initWithEntity:FeedArticle.entity insertIntoManagedObjectContext:self.managedObjectContext]; - fa.sortIndex = idx; - fa.unread = YES; - fa.guid = entry.guid; - fa.title = entry.title; - fa.abstract = entry.abstract; - fa.body = entry.body; - fa.author = entry.author; - fa.link = entry.link; - fa.published = entry.datePublished; - if (!fa.published) - fa.published = entry.dateModified; - [self addArticlesObject:fa]; - return fa; + return newOnes; } /** Delete all items where @c link matches one of the URLs in the @c NSSet. */ -- (void)deleteArticlesWithLink:(NSMutableSet*)urls { +- (NSUInteger)deleteArticlesWithLink:(NSMutableSet*)urls { if (!urls || urls.count == 0) - return; + return 0; + NSUInteger c = 0; for (FeedArticle *fa in self.articles) { if ([urls containsObject:fa.link]) { [urls removeObject:fa.link]; - if (fa.unread) - self.unreadCount -= 1; + if (fa.unread) ++c; // TODO: keep unread articles? [self.managedObjectContext deleteObject:fa]; if (urls.count == 0) @@ -175,6 +166,7 @@ if (delArticles.count > 0) { [self removeArticles:delArticles]; } + return c; } @@ -201,41 +193,6 @@ return nil; } -/** - For all articles set @c unread @c = @c NO - - @return Change in unread count. (0 or negative number) - */ -- (int)markAllItemsRead { - return [self markAllArticlesRead:YES]; -} - -/** - For all articles set @c unread @c = @c YES - - @return Change in unread count. (0 or positive number) - */ -- (int)markAllItemsUnread { - return [self markAllArticlesRead:NO]; -} - -/** - Mark all articles read or unread and update @c unreadCount - - @param readFlag @c YES: mark items read; @c NO: mark items unread - */ -- (int)markAllArticlesRead:(BOOL)readFlag { - for (FeedArticle *fa in self.articles) { - if (fa.unread == readFlag) - fa.unread = !readFlag; - } - int32_t oldCount = self.unreadCount; - int32_t newCount = (readFlag ? 0 : (int32_t)self.articles.count); - if (self.unreadCount != newCount) - self.unreadCount = newCount; - return newCount - oldCount; -} - #pragma mark - Icon - @@ -262,7 +219,7 @@ } else { - static NSImage *defaultRSSIcon; + static NSImage *defaultRSSIcon; // TODO: setup imageNamed: for default rss icon if (!defaultRSSIcon) defaultRSSIcon = [RSSIcon iconWithSize:16]; return defaultRSSIcon; diff --git a/baRSS/Core Data/FeedArticle+Ext.h b/baRSS/Core Data/FeedArticle+Ext.h new file mode 100644 index 0000000..d6f7b13 --- /dev/null +++ b/baRSS/Core Data/FeedArticle+Ext.h @@ -0,0 +1,31 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 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 "FeedArticle+CoreDataClass.h" +#import + +@class RSParsedArticle; + +@interface FeedArticle (Ext) ++ (instancetype)newArticle:(RSParsedArticle*)entry inContext:(NSManagedObjectContext*)moc; +- (NSMenuItem*)newMenuItem; +@end diff --git a/baRSS/Core Data/FeedArticle+Ext.m b/baRSS/Core Data/FeedArticle+Ext.m new file mode 100644 index 0000000..4dbcff4 --- /dev/null +++ b/baRSS/Core Data/FeedArticle+Ext.m @@ -0,0 +1,94 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 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 "FeedArticle+Ext.h" +#import "Constants.h" +#import "UserPrefs.h" +#import "StoreCoordinator.h" + +#import + +@implementation FeedArticle (Ext) + +/// Create new article based on RSXML article input. ++ (instancetype)newArticle:(RSParsedArticle*)entry inContext:(NSManagedObjectContext*)moc { + FeedArticle *fa = [[FeedArticle alloc] initWithEntity:FeedArticle.entity insertIntoManagedObjectContext:moc]; + fa.unread = YES; + fa.guid = entry.guid; + fa.title = entry.title; + fa.abstract = entry.abstract; + fa.body = entry.body; + fa.author = entry.author; + fa.link = entry.link; + fa.published = entry.datePublished; + if (!fa.published) + fa.published = entry.dateModified; + return fa; +} + +/// @return Full or truncated article title, based on user preference in settings. +- (NSString*)shortArticleName { + NSString *title = self.title; + if (!title) return @""; + // TODO: It should be enough to get user prefs once per menu build + if ([UserPrefs defaultNO:@"feedShortNames"]) { + NSUInteger limit = [UserPrefs shortArticleNamesLimit]; + if (title.length > limit) + title = [NSString stringWithFormat:@"%@…", [title substringToIndex:limit-1]]; + } + return title; +} + +/// @return Fully initialized @c NSMenuItem with @c title, @c tooltip, @c tickmark, and @c action. +- (NSMenuItem*)newMenuItem { + NSMenuItem *item = [NSMenuItem new]; + item.title = [self shortArticleName]; + item.enabled = (self.link.length > 0); + item.state = (self.unread && [UserPrefs defaultYES:@"feedTickMark"] ? NSControlStateValueOn : NSControlStateValueOff); + //mi.toolTip = item.abstract; + // TODO: Do regex during save, not during display. Its here for testing purposes ... + if (self.abstract.length > 0) { + NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"<[^>]*>" options:kNilOptions error:nil]; + item.toolTip = [regex stringByReplacingMatchesInString:self.abstract options:kNilOptions range:NSMakeRange(0, self.abstract.length) withTemplate:@""]; + } + item.representedObject = self.objectID; + item.target = [self class]; + item.action = @selector(didClickOnMenuItem:); + return item; +} + +/// Callback method for @c NSMenuItem. Will open url associated with @c FeedArticle and mark it read. ++ (void)didClickOnMenuItem:(NSMenuItem*)sender { + NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; + FeedArticle *fa = [moc objectWithID:sender.representedObject]; + NSString *url = fa.link; + if (fa.unread) { + fa.unread = NO; + [StoreCoordinator saveContext:moc andParent:YES]; + [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged object:@-1]; + } + [moc reset]; + if (url && url.length > 0) + [UserPrefs openURLsWithPreferredBrowser:@[[NSURL URLWithString:url]]]; +} + +@end diff --git a/baRSS/Categories/FeedGroup+Ext.h b/baRSS/Core Data/FeedGroup+Ext.h similarity index 95% rename from baRSS/Categories/FeedGroup+Ext.h rename to baRSS/Core Data/FeedGroup+Ext.h index ae72665..08b7ad5 100644 --- a/baRSS/Categories/FeedGroup+Ext.h +++ b/baRSS/Core Data/FeedGroup+Ext.h @@ -21,6 +21,7 @@ // SOFTWARE. #import "FeedGroup+CoreDataClass.h" +#import /// Enum type to distinguish different @c FeedGroup types: @c GROUP, @c FEED, @c SEPARATOR typedef NS_ENUM(int16_t, FeedGroupType) { @@ -32,11 +33,13 @@ typedef NS_ENUM(int16_t, FeedGroupType) { @interface FeedGroup (Ext) /// Overwrites @c type attribute with enum. Use one of: @c GROUP, @c FEED, @c SEPARATOR. @property (nonatomic) FeedGroupType type; +@property (nonnull, readonly) NSString *nameOrError; + (instancetype)newGroup:(FeedGroupType)type inContext:(NSManagedObjectContext*)context; - (void)setParent:(FeedGroup *)parent andSortIndex:(int32_t)sortIndex; - (void)setNameIfChanged:(NSString*)name; - (NSImage*)groupIconImage16; +- (NSMenuItem*)newMenuItem; // Handle children and parents - (NSString*)indexPathString; - (NSArray*)sortedChildren; diff --git a/baRSS/Categories/FeedGroup+Ext.m b/baRSS/Core Data/FeedGroup+Ext.m similarity index 90% rename from baRSS/Categories/FeedGroup+Ext.m rename to baRSS/Core Data/FeedGroup+Ext.m index cd1171d..2a99985 100644 --- a/baRSS/Categories/FeedGroup+Ext.m +++ b/baRSS/Core Data/FeedGroup+Ext.m @@ -59,6 +59,21 @@ return groupIcon; } +/// @return Returns "(error)" if @c self.name is @c nil. +- (nonnull NSString*)nameOrError { + return (self.name ? self.name : NSLocalizedString(@"(error)", nil)); +} + +/// @return Fully initialized @c NSMenuItem with @c title and @c image. +- (NSMenuItem*)newMenuItem { + NSMenuItem *item = [NSMenuItem new]; + item.title = self.nameOrError; + item.enabled = (self.children.count > 0); + item.image = [self groupIconImage16]; + item.representedObject = self.objectID; + return item; +} + #pragma mark - Handle Children And Parents - diff --git a/baRSS/Categories/FeedMeta+Ext.h b/baRSS/Core Data/FeedMeta+Ext.h similarity index 100% rename from baRSS/Categories/FeedMeta+Ext.h rename to baRSS/Core Data/FeedMeta+Ext.h diff --git a/baRSS/Categories/FeedMeta+Ext.m b/baRSS/Core Data/FeedMeta+Ext.m similarity index 100% rename from baRSS/Categories/FeedMeta+Ext.m rename to baRSS/Core Data/FeedMeta+Ext.m diff --git a/baRSS/Core Data/NSFetchRequest+Ext.h b/baRSS/Core Data/NSFetchRequest+Ext.h new file mode 100644 index 0000000..51ce640 --- /dev/null +++ b/baRSS/Core Data/NSFetchRequest+Ext.h @@ -0,0 +1,38 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 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 + +@interface NSFetchRequest (Ext) +// Perform core data request and fetch data +- (NSArray*)fetchAllRows:(NSManagedObjectContext*)moc; +- (NSArray*)fetchIDs:(NSManagedObjectContext*)moc; +- (NSUInteger)fetchCount:(NSManagedObjectContext*)moc; +- (id)fetchFirst:(NSManagedObjectContext*)moc; // limit 1 + +// Selecting, filtering, sorting results +- (instancetype)select:(NSArray*)cols; // sets .propertiesToFetch +- (instancetype)where:(NSString*)format, ...; // sets .predicate +- (instancetype)sortASC:(NSString*)key; // add .sortDescriptors -> ascending:YES +- (instancetype)sortDESC:(NSString*)key; // add .sortDescriptors -> ascending:NO +- (instancetype)addFunctionExpression:(NSString*)fn onKeyPath:(NSString*)keyPath name:(NSString*)name type:(NSAttributeType)type; // add .propertiesToFetch -> (expressionForFunction:@[expressionForKeyPath:]) +@end diff --git a/baRSS/Core Data/NSFetchRequest+Ext.m b/baRSS/Core Data/NSFetchRequest+Ext.m new file mode 100644 index 0000000..232747b --- /dev/null +++ b/baRSS/Core Data/NSFetchRequest+Ext.m @@ -0,0 +1,133 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 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 "NSFetchRequest+Ext.h" + +@implementation NSFetchRequest (Ext) + +/// Perform fetch and return result. If an error occurs, print it to the console. +- (NSArray*)fetchAllRows:(NSManagedObjectContext*)moc { + NSError *err; + NSArray *fetchResults = [moc executeFetchRequest:self error:&err]; + if (err) NSLog(@"ERROR: Fetch request failed: %@", err); + //NSLog(@"%@ ==> %@", self, fetchResults); // debugging + return fetchResults; +} + +/// Set @c resultType to @c NSManagedObjectIDResultType and return list of object ids. +- (NSArray*)fetchIDs:(NSManagedObjectContext*)moc { + self.includesPropertyValues = NO; + self.resultType = NSManagedObjectIDResultType; + return [self fetchAllRows:moc]; +} + +/// Set @c limit to @c 1 and fetch first objcect. May return object type or @c NSDictionary if @c resultType @c = @c NSManagedObjectIDResultType. +- (id)fetchFirst:(NSManagedObjectContext*)moc { + self.fetchLimit = 1; + return [[self fetchAllRows:moc] firstObject]; +} + +/// Convenient method to return the number of rows that match the request. +- (NSUInteger)fetchCount:(NSManagedObjectContext*)moc { + return [moc countForFetchRequest:self error:nil]; +} + +#pragma mark - Selecting, Filtering, Sorting + +/** + Set @c self.propertiesToFetch = @c cols and @c self.resultType = @c NSDictionaryResultType. + @return @c self (e.g., method chaining) + */ +- (instancetype)select:(NSArray*)cols { + self.propertiesToFetch = cols; + self.resultType = NSDictionaryResultType; + return self; +} + +/** + Set @c self.predicate = [NSPredicate predicateWithFormat: @c format ] + @return @c self (e.g., method chaining) + */ +- (instancetype)where:(NSString*)format, ... { + va_list arguments; + va_start(arguments, format); + self.predicate = [NSPredicate predicateWithFormat:format arguments:arguments]; + va_end(arguments); + return self; +} + +/** + Add new [NSSortDescriptor sortDescriptorWithKey: @c key ascending:YES] to @c self.sortDescriptors. + @return @c self (e.g., method chaining) + */ +- (instancetype)sortASC:(NSString*)key { + [self addSortingKey:key asc:YES]; + return self; +} + +/** + Add new [NSSortDescriptor sortDescriptorWithKey: @c key ascending:NO] to @c self.sortDescriptors. + @return @c self (e.g., method chaining) + */ +- (instancetype)sortDESC:(NSString*)key { + [self addSortingKey:key asc:NO]; + return self; +} + +/** + Add new [NSExpression expressionForFunction: @c fn arguments: [NSExpression expressionForKeyPath: @c keyPath ]] to @c self.propertiesToFetch. + Also set @c self.includesPropertyValues @c = @c NO and @c self.resultType @c = @c NSDictionaryResultType. + @return @c self (e.g., method chaining) + */ +- (instancetype)addFunctionExpression:(NSString*)fn onKeyPath:(NSString*)keyPath name:(NSString*)name type:(NSAttributeType)type { + [self addExpression:[NSExpression expressionForFunction:fn arguments:@[[NSExpression expressionForKeyPath:keyPath]]] name:name type:type]; + return self; +} + +#pragma mark - Helper + +/// Add @c NSSortDescriptor to existing list of @c sortDescriptors. +- (void)addSortingKey:(NSString*)key asc:(BOOL)flag { + NSSortDescriptor *sd = [NSSortDescriptor sortDescriptorWithKey:key ascending:flag]; + if (!self.sortDescriptors) { + self.sortDescriptors = @[ sd ]; + } else { + self.sortDescriptors = [self.sortDescriptors arrayByAddingObject:sd]; + } +} + +/// Add @c NSExpressionDescription to existing list of @c propertiesToFetch. +- (void)addExpression:(NSExpression*)exp name:(NSString*)name type:(NSAttributeType)type { + self.includesPropertyValues = NO; + self.resultType = NSDictionaryResultType; + NSExpressionDescription *expDesc = [[NSExpressionDescription alloc] init]; + [expDesc setName:name]; + [expDesc setExpression:exp]; + [expDesc setExpressionResultType:type]; + if (!self.propertiesToFetch) { + self.propertiesToFetch = @[ expDesc ]; + } else { + self.propertiesToFetch = [self.propertiesToFetch arrayByAddingObject:expDesc]; + } +} + +@end diff --git a/baRSS/StoreCoordinator.h b/baRSS/Core Data/StoreCoordinator.h similarity index 68% rename from baRSS/StoreCoordinator.h rename to baRSS/Core Data/StoreCoordinator.h index 737ee64..3ae4cd2 100644 --- a/baRSS/StoreCoordinator.h +++ b/baRSS/Core Data/StoreCoordinator.h @@ -27,19 +27,29 @@ // Managing contexts + (NSManagedObjectContext*)createChildContext; + (void)saveContext:(NSManagedObjectContext*)context andParent:(BOOL)flag; + // Feed update -+ (NSArray*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc; + (NSDate*)nextScheduledUpdate; -// Main menu display -+ (NSInteger)unreadCountForIndexPathString:(NSString*)str; -+ (NSArray*)sortedObjectIDsForParent:(id)parent isFeed:(BOOL)flag inContext:(NSManagedObjectContext*)moc; -// OPML import & export -+ (NSInteger)numberRootItemsInContext:(NSManagedObjectContext*)moc; -+ (NSArray*)sortedListOfRootObjectsInContext:(NSManagedObjectContext*)moc; -// Restore sound state -+ (NSUInteger)deleteAllGroups; -+ (NSUInteger)deleteUnreferenced; -+ (void)restoreFeedCountsAndIndexPaths; ++ (NSArray*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc; + +// Count elements ++ (NSUInteger)countTotalUnread; ++ (NSUInteger)countRootItemsInContext:(NSManagedObjectContext*)moc; ++ (NSArray*)countAggregatedUnread; + +// Get List Of Elements ++ (NSArray*)sortedFeedGroupsWithParent:(id)parent inContext:(NSManagedObjectContext*)moc; ++ (NSArray*)sortedArticlesWithParent:(id)parent inContext:(NSManagedObjectContext*)moc; + (NSArray*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc; + (NSArray*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc; ++ (Feed*)feedWithIndexPath:(nonnull NSString*)path inContext:(NSManagedObjectContext*)moc; ++ (NSString*)urlForFeedWithIndexPath:(nonnull NSString*)path; + +// Unread articles list & mark articled read ++ (NSArray*)articlesAtPath:(nullable NSString*)path isFeed:(BOOL)feedFlag sorted:(BOOL)sortFlag unread:(BOOL)readFlag inContext:(NSManagedObjectContext*)moc limit:(NSUInteger)limit; + +// Restore sound state ++ (void)restoreFeedIndexPaths; ++ (NSUInteger)deleteUnreferenced; ++ (NSUInteger)deleteAllGroups; @end diff --git a/baRSS/Core Data/StoreCoordinator.m b/baRSS/Core Data/StoreCoordinator.m new file mode 100644 index 0000000..d475fd4 --- /dev/null +++ b/baRSS/Core Data/StoreCoordinator.m @@ -0,0 +1,253 @@ +// +// 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 "StoreCoordinator.h" +#import "NSFetchRequest+Ext.h" +#import "AppHook.h" +#import "Feed+Ext.h" + +@implementation StoreCoordinator + +#pragma mark - Managing contexts + +/// @return The application main persistent context. ++ (NSManagedObjectContext*)getMainContext { + return [(AppHook*)NSApp persistentContainer].viewContext; +} + +/// New child context with @c NSMainQueueConcurrencyType and without undo manager. ++ (NSManagedObjectContext*)createChildContext { + NSManagedObjectContext *context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType]; + [context setParentContext:[self getMainContext]]; + context.undoManager = nil; + //context.automaticallyMergesChangesFromParent = YES; + return context; +} + +/** + Commit changes and perform save operation on @c context. + + @param flag If @c YES save any parent context as well (recursive). + */ ++ (void)saveContext:(NSManagedObjectContext*)context andParent:(BOOL)flag { + // Performs the save action for the application, which is to send the save: message to the application's managed object context. Any encountered errors are presented to the user. + if (![context commitEditing]) { + NSLog(@"%@:%@ unable to commit editing before saving", [self class], NSStringFromSelector(_cmd)); + } + NSError *error = nil; + if (context.hasChanges && ![context save:&error]) { + // Customize this code block to include application-specific recovery steps. + [[NSApplication sharedApplication] presentError:error]; + } + if (flag && context.parentContext) { + [self saveContext:context.parentContext andParent:flag]; + } +} + + +#pragma mark - Feed Update + +/// @return @c NSDate of next (earliest) feed update. May be @c nil. ++ (NSDate*)nextScheduledUpdate { + NSFetchRequest *fr = [FeedMeta fetchRequest]; + [fr addFunctionExpression:@"min:" onKeyPath:@"scheduled" name:@"minDate" type:NSDateAttributeType]; + return [fr fetchAllRows: [self getMainContext]].firstObject[@"minDate"]; +} + +/** + List of @c Feed items that need to be updated. Scheduled time is now (or in past). + + @param forceAll If @c YES get a list of all @c Feed regardless of schedules time. + */ ++ (NSArray*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc { + NSFetchRequest *fr = [Feed fetchRequest]; + if (!forceAll) { + // when fetching also get those feeds that would need update soon (now + 10s) + [fr where:@"meta.scheduled <= %@", [NSDate dateWithTimeIntervalSinceNow:+10]]; + } + return [fr fetchAllRows:moc]; +} + + +#pragma mark - Count Elements + +/// @return Sum of all unread @c FeedArticle items. ++ (NSUInteger)countTotalUnread { + return [[[FeedArticle fetchRequest] where:@"unread = YES"] fetchCount: [self getMainContext]]; +} + +/// @return Count of objects at root level. Aka @c sortIndex for the next @c FeedGroup item. ++ (NSUInteger)countRootItemsInContext:(NSManagedObjectContext*)moc { + return [[[FeedGroup fetchRequest] where:@"parent = NULL"] fetchCount:moc]; +} + +/// @return Unread and total count grouped by @c Feed item. ++ (NSArray*)countAggregatedUnread { + NSFetchRequest *fr = [Feed fetchRequest]; + fr.propertiesToGroupBy = @[ @"indexPath" ]; + fr.propertiesToFetch = @[ @"indexPath" ]; + [fr addFunctionExpression:@"sum:" onKeyPath:@"articles.unread" name:@"unread" type:NSInteger32AttributeType]; + [fr addFunctionExpression:@"count:" onKeyPath:@"articles.unread" name:@"total" type:NSInteger32AttributeType]; + return [fr fetchAllRows: [self getMainContext]]; +} + + +#pragma mark - Get List Of Elements + +/// @return Sorted list of @c FeedGroup items where @c FeedGroup.parent @c = @c parent. ++ (NSArray*)sortedFeedGroupsWithParent:(id)parent inContext:(NSManagedObjectContext*)moc { + return [[[[FeedGroup fetchRequest] where:@"parent = %@", parent] sortASC:@"sortIndex"] fetchAllRows:moc]; +} + +/// @return Sorted list of @c FeedArticle items where @c FeedArticle.feed @c = @c parent. ++ (NSArray*)sortedArticlesWithParent:(id)parent inContext:(NSManagedObjectContext*)moc { + return [[[[FeedArticle fetchRequest] where:@"feed = %@", parent] sortDESC:@"sortIndex"] fetchAllRows:moc]; +} + +/// @return Unsorted list of @c Feed items where @c articles.count @c == @c 0. ++ (NSArray*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc { + return [[[Feed fetchRequest] where:@"articles.@count == 0"] fetchAllRows:moc]; +} + +/// @return Unsorted list of @c Feed items where @c icon is @c nil. ++ (NSArray*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc { + return [[[Feed fetchRequest] where:@"icon = NULL"] fetchAllRows:moc]; +} + +/// @return Single @c Feed item where @c Feed.indexPath @c = @c path. ++ (Feed*)feedWithIndexPath:(nonnull NSString*)path inContext:(NSManagedObjectContext*)moc { + return [[[Feed fetchRequest] where:@"indexPath = %@", path] fetchFirst:moc]; +} + +/// @return URL of @c Feed item where @c Feed.indexPath @c = @c path. ++ (NSString*)urlForFeedWithIndexPath:(nonnull NSString*)path { + return [[[[Feed fetchRequest] where:@"indexPath = %@", path] select:@[@"link"]] fetchFirst: [self getMainContext]][@"link"]; +} + +/// @return Unsorted list of object IDs where @c Feed.indexPath begins with @c path @c + @c "." ++ (NSArray*)feedIDsForIndexPath:(nonnull NSString*)path inContext:(NSManagedObjectContext*)moc { + return [[[Feed fetchRequest] where:@"indexPath BEGINSWITH %@", [path stringByAppendingString:@"."]] fetchIDs:moc]; +} + + +#pragma mark - Unread Articles List & Mark Read + +/// @return Return predicate that will match either exactly one, @b or a list of, @b or all @c Feed items. ++ (nullable NSPredicate*)predicateWithPath:(nullable NSString*)path isFeed:(BOOL)flag inContext:(NSManagedObjectContext*)moc { + if (!path) return nil; // match all + if (flag) { + Feed *obj = [self feedWithIndexPath:path inContext:moc]; + return [NSPredicate predicateWithFormat:@"feed = %@", obj.objectID]; + } + NSArray *list = [self feedIDsForIndexPath:path inContext:moc]; + if (list && list.count > 0) { + return [NSPredicate predicateWithFormat:@"feed IN %@", list]; + } + return [NSPredicate predicateWithValue:NO]; // match none +} + +/** + Return object list with @c FeedArticle where @c unread @c = @c YES. In the same order the user provided. + + @param path Match @c Feed items where @c indexPath string matches @c path. + @param feedFlag If @c YES path must match exactly. If @c NO match items that begin with @c path + @c "." + @param sortFlag Whether articles should be returned in sorted order (e.g., for 'open all unread'). + @param readFlag Match @c FeedArticle where @c unread @c = @c readFlag. + @param limit Only return first @c X articles that match the criteria. + @return Sorted list of @c FeedArticle with @c unread @c = @c YES. + */ ++ (NSArray*)articlesAtPath:(nullable NSString*)path isFeed:(BOOL)feedFlag sorted:(BOOL)sortFlag unread:(BOOL)readFlag inContext:(NSManagedObjectContext*)moc limit:(NSUInteger)limit { + NSFetchRequest *fr = [[FeedArticle fetchRequest] where:@"unread = %d", readFlag]; + fr.fetchLimit = limit; + if (sortFlag) { + if (!path || !feedFlag) + [fr sortASC:@"feed.indexPath"]; + [fr sortDESC:@"sortIndex"]; + } + /* UNUSED. Batch updates will break NSUndoManager in preferences. Fix that before usage. + NSBatchUpdateRequest *bur = [NSBatchUpdateRequest batchUpdateRequestWithEntityName: FeedArticle.entity.name]; + bur.propertiesToUpdate = @{ @"unread": @(!readFlag) }; + bur.resultType = NSUpdatedObjectIDsResultType; + bur.predicate = [NSPredicate predicateWithFormat:@"unread = %d", readFlag];*/ + NSPredicate *feedFilter = [self predicateWithPath:path isFeed:feedFlag inContext:moc]; + if (feedFilter) + fr.predicate = [NSCompoundPredicate andPredicateWithSubpredicates:@[fr.predicate, feedFilter]]; + return [fr fetchAllRows:moc]; +} + + +#pragma mark - Restore Sound State + +/// Iterate over all @c Feed and re-calculate @c indexPath. ++ (void)restoreFeedIndexPaths { + NSManagedObjectContext *moc = [self getMainContext]; + for (Feed *f in [[Feed fetchRequest] fetchAllRows:moc]) { + [f calculateAndSetIndexPathString]; + } + [self saveContext:moc andParent:YES]; + [moc reset]; +} + +/** + Delete all @c Feed items where @c group @c = @c NULL and all @c FeedMeta, @c FeedIcon, @c FeedArticle where @c feed @c = @c NULL. + */ ++ (NSUInteger)deleteUnreferenced { + NSUInteger deleted = 0; + NSManagedObjectContext *moc = [self getMainContext]; + deleted += [self batchDelete:Feed.entity nullAttribute:@"group" inContext:moc]; + deleted += [self batchDelete:FeedMeta.entity nullAttribute:@"feed" inContext:moc]; + deleted += [self batchDelete:FeedIcon.entity nullAttribute:@"feed" inContext:moc]; + deleted += [self batchDelete:FeedArticle.entity nullAttribute:@"feed" inContext:moc]; + if (deleted > 0) { + [self saveContext:moc andParent:YES]; + [moc reset]; + } + return deleted; +} + +/// Delete all @c FeedGroup items. ++ (NSUInteger)deleteAllGroups { + NSManagedObjectContext *moc = [self getMainContext]; + NSUInteger deleted = [self batchDelete:FeedGroup.entity nullAttribute:nil inContext:moc]; + [self saveContext:moc andParent:YES]; + [moc reset]; + return deleted; +} + +/** + Perform batch delete on entities of type @c entity where @c column @c IS @c NULL. If @c column is @c nil, delete all rows. + */ ++ (NSUInteger)batchDelete:(NSEntityDescription*)entity nullAttribute:(NSString*)column inContext:(NSManagedObjectContext*)moc { + NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: entity.name]; + if (column && column.length > 0) { + // using @count here to also find items where foreign key is set but referencing a non-existing object. + fr.predicate = [NSPredicate predicateWithFormat:@"count(%K) == 0", column]; + } + NSBatchDeleteRequest *bdr = [[NSBatchDeleteRequest alloc] initWithFetchRequest:fr]; + bdr.resultType = NSBatchDeleteResultTypeCount; + NSError *err; + NSBatchDeleteResult *res = [moc executeRequest:bdr error:&err]; + if (err) NSLog(@"%@", err); + return [res.result unsignedIntegerValue]; +} + +@end diff --git a/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents b/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents index c336d83..b777361 100644 --- a/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents +++ b/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents @@ -5,7 +5,6 @@ - @@ -45,7 +44,7 @@ - + diff --git a/baRSS/FeedDownload.h b/baRSS/Helper/FeedDownload.h similarity index 100% rename from baRSS/FeedDownload.h rename to baRSS/Helper/FeedDownload.h diff --git a/baRSS/FeedDownload.m b/baRSS/Helper/FeedDownload.m similarity index 99% rename from baRSS/FeedDownload.m rename to baRSS/Helper/FeedDownload.m index 2e59661..e91d1d9 100644 --- a/baRSS/FeedDownload.m +++ b/baRSS/Helper/FeedDownload.m @@ -301,7 +301,7 @@ static BOOL _nextUpdateIsForced = NO; } else { success = YES; [f.meta setSucessfulWithResponse:response]; - if (rss) { + if (rss && rss.articles.count > 0) { [f updateWithRSS:rss postUnreadCountChange:YES]; needsNotification = YES; } diff --git a/baRSS/Helper/NSDate+Ext.h b/baRSS/Helper/NSDate+Ext.h index 930d010..058d7af 100644 --- a/baRSS/Helper/NSDate+Ext.h +++ b/baRSS/Helper/NSDate+Ext.h @@ -1,5 +1,25 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 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 #import typedef int32_t Interval; @@ -14,11 +34,12 @@ typedef NS_ENUM(int32_t, TimeUnitType) { @interface NSDate (Ext) + (nonnull NSString*)stringForInterval:(Interval)intv rounded:(BOOL)flag; ++ (TimeUnitType)unitForInterval:(Interval)intv rounded:(BOOL)flag; @end @interface NSDate (RefreshControlsUI) + (Interval)intervalForPopup:(NSPopUpButton*)unit andField:(NSTextField*)value; -+ (void)setInterval:(Interval)intv forPopup:(NSPopUpButton*)popup andField:(NSTextField*)field; ++ (void)setInterval:(Interval)intv forPopup:(NSPopUpButton*)popup andField:(NSTextField*)field animate:(BOOL)flag; + (void)populateUnitsMenu:(NSPopUpButton*)popup selected:(TimeUnitType)unit; @end diff --git a/baRSS/Helper/NSDate+Ext.m b/baRSS/Helper/NSDate+Ext.m index f542b5a..27e5051 100644 --- a/baRSS/Helper/NSDate+Ext.m +++ b/baRSS/Helper/NSDate+Ext.m @@ -1,6 +1,29 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 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 "NSDate+Ext.h" +#import + static const char _shortnames[] = {'y','w','d','h','m','s'}; static const char *_names[] = {"Years", "Weeks", "Days", "Hours", "Minutes", "Seconds"}; static const TimeUnitType _values[] = { @@ -15,6 +38,7 @@ static const TimeUnitType _values[] = { @implementation NSDate (Ext) +/// If @c flag @c = @c YES, print @c 1.1f float string with single char unit: e.g., 3.3m, 1.7h. + (nonnull NSString*)stringForInterval:(Interval)intv rounded:(BOOL)flag { if (flag) { unsigned short i = [self floatUnitIndexForInterval:abs(intv)]; @@ -80,10 +104,13 @@ static const TimeUnitType _values[] = { } /// Configure both @c NSControl elements based on the provided interval @c intv. -+ (void)setInterval:(Interval)intv forPopup:(NSPopUpButton*)popup andField:(NSTextField*)field { ++ (void)setInterval:(Interval)intv forPopup:(NSPopUpButton*)popup andField:(NSTextField*)field animate:(BOOL)flag { TimeUnitType unit = [self unitForInterval:intv rounded:NO]; + int num = (int)(intv / unit); + if (flag && popup.selectedTag != unit) [self animateControlSize:popup]; + if (flag && field.intValue != num) [self animateControlSize:field]; [popup selectItemWithTag:unit]; - field.intValue = (int)(intv / unit); + field.intValue = num; } /// Insert all @c TimeUnitType items into popup button. Save unit value into @c tag attribute. @@ -98,4 +125,17 @@ static const TimeUnitType _values[] = { [popup selectItemWithTag:unit]; } +/// Helper method to animate @c NSControl to draw user attention. View will be scalled up in a fraction of a second. ++ (void)animateControlSize:(NSView*)control { + CABasicAnimation *scale = [CABasicAnimation animationWithKeyPath:@"transform"]; + CATransform3D tr = CATransform3DIdentity; + tr = CATransform3DTranslate(tr, NSMidX(control.bounds), NSMidY(control.bounds), 0); + tr = CATransform3DScale(tr, 1.1, 1.1, 1); + tr = CATransform3DTranslate(tr, -NSMidX(control.bounds), -NSMidY(control.bounds), 0); + scale.toValue = [NSValue valueWithCATransform3D:tr]; + scale.duration = 0.15f; + scale.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseIn]; + [control.layer addAnimation:scale forKey:scale.keyPath]; +} + @end diff --git a/baRSS/Helper/Statistics.m b/baRSS/Helper/Statistics.m index 2d28849..f761d64 100644 --- a/baRSS/Helper/Statistics.m +++ b/baRSS/Helper/Statistics.m @@ -21,6 +21,7 @@ // SOFTWARE. #import "Statistics.h" +#import "NSDate+Ext.h" @implementation Statistics @@ -51,56 +52,24 @@ if (differences.count == 0) return nil; - [differences sortUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"intValue" ascending:YES]]]; + [differences sortUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"integerValue" ascending:YES]]]; - NSUInteger i = differences.count; - NSUInteger mid = (i/2); - unsigned int med = differences[mid].unsignedIntValue; - if (i > 1 && (i % 1) == 0) { // even feed count, use median of two values - med = (med + differences[mid+1].unsignedIntValue) / 2; + NSUInteger i = (differences.count/2); + NSNumber *median = differences[i]; + if ((differences.count % 2) == 0) { // even feed count, use median of two values + median = [NSNumber numberWithInteger:(median.integerValue + differences[i-1].integerValue) / 2]; } - return @{@"min" : [self stringForInterval:differences.firstObject.unsignedIntValue], - @"max" : [self stringForInterval:differences.lastObject.unsignedIntValue], - @"avg" : [self stringForInterval:[(NSNumber*)[differences valueForKeyPath:@"@avg.self"] unsignedIntValue]], - @"median" : [self stringForInterval:med], + return @{@"min" : differences.firstObject, + @"max" : differences.lastObject, + @"avg" : [differences valueForKeyPath:@"@avg.self"], + @"median" : median, @"earliest" : earliest, @"latest" : latest }; } -/// Print @c 1.1f float string with single char unit: e.g., 3.3m, 1.7h -+ (NSString*)stringForInterval:(unsigned int)val { - float i; - NSUInteger u = [self findAppropriateTimeUnit:val interval:&i]; - return [NSString stringWithFormat:@"%1.1f%c", i, [@"smhdw" characterAtIndex:u]]; -} - -/// @return Unit as int @c (0-4) (0: seconds - 4: weeks). Sets division result @c intv. -+ (NSUInteger)findAppropriateTimeUnit:(unsigned int)val interval:(float*)intv { - if (val > 604800) {*intv = (val / 604800.f); return 4;} // weeks - if (val > 86400) {*intv = (val / 86400.f); return 3;} // days - if (val > 3600) {*intv = (val / 3600.f); return 2;} // hours - if (val > 60) {*intv = (val / 60.f); return 1;} // minutes - *intv = (val / 1.f); - return 0; -} - -/// @return Single integer value that combines refresh interval and refresh unit. To be used as @c NSButton.tag -+ (NSInteger)buttonTagFromRefreshString:(NSString*)str { - NSInteger refresh = (NSInteger)roundf([str floatValue]) << 3; - switch ([str characterAtIndex:(str.length - 1)]) { - case 's': return 0 | refresh; - case 'm': return 1 | refresh; - case 'h': return 2 | refresh; - case 'd': return 3 | refresh; - case 'w': return 4 | refresh; - } - return 0; // error, should never happen though -} - #pragma mark - Feed Statistics UI - /** Generate UI with buttons for min, max, avg and median. Also show number of articles and latest article date. @@ -120,8 +89,7 @@ NSPoint origin = NSZeroPoint; for (NSString *str in @[@"min", @"max", @"avg", @"median"]) { NSString *title = [str stringByAppendingString:@":"]; - NSString *value = [info valueForKey:str]; - NSView *v = [self viewWithLabel:title andRefreshButton:value callback:callback]; + NSView *v = [self viewWithLabel:title andInterval:info[str] callback:callback]; [v setFrameOrigin:origin]; [buttonsView addSubview:v]; origin.x += NSWidth(v.frame); @@ -161,11 +129,8 @@ /** Create view with duration button, e.g., '3.4h' and label infornt of it. */ -+ (NSView*)viewWithLabel:(NSString*)title andRefreshButton:(NSString*)value callback:(nullable id)callback { ++ (NSView*)viewWithLabel:(NSString*)title andInterval:(NSNumber*)value callback:(nullable id)callback { static const int buttonPadding = 5; - if (!value || value.length == 0) - return nil; - NSButton *button = [self grayInlineButton:value]; if (callback) { button.target = callback; @@ -194,12 +159,13 @@ /** @return Rounded, gray inline button with tag equal to refresh interval. */ -+ (NSButton*)grayInlineButton:(NSString*)text { - NSButton *button = [NSButton buttonWithTitle:text target:nil action:nil]; ++ (NSButton*)grayInlineButton:(NSNumber*)num { + NSButton *button = [NSButton buttonWithTitle:[NSDate stringForInterval:num.intValue rounded:YES] target:nil action:nil]; button.font = [NSFont monospacedDigitSystemFontOfSize: NSFont.labelFontSize weight:NSFontWeightBold]; button.bezelStyle = NSBezelStyleInline; button.controlSize = NSControlSizeSmall; - button.tag = [self buttonTagFromRefreshString:text]; + TimeUnitType unit = [NSDate unitForInterval:num.intValue rounded:YES]; + button.tag = (NSInteger)(roundf(num.floatValue / unit) * unit); // rounded inteval [button sizeToFit]; return button; } diff --git a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m index dcf9033..f34d565 100644 --- a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m +++ b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m @@ -29,8 +29,6 @@ #import "Statistics.h" #import "NSDate+Ext.h" -#import - #pragma mark - ModalEditDialog - @@ -106,7 +104,7 @@ self.url.objectValue = fg.feed.meta.url; self.previousURL = self.url.stringValue; self.warningIndicator.image = [fg.feed iconImage16]; - [NSDate setInterval:fg.feed.meta.refresh forPopup:self.refreshUnit andField:self.refreshNum]; + [NSDate setInterval:fg.feed.meta.refresh forPopup:self.refreshUnit andField:self.refreshNum animate:NO]; [self statsForCoreDataObject]; } @@ -288,29 +286,7 @@ /// Callback method for @c Statistics @c +viewForRefreshInterval:articleCount:callback: - (void)refreshIntervalButtonClicked:(NSButton *)sender { - NSInteger num = (sender.tag >> 3); - NSInteger unit = (sender.tag & 0x7); - if (self.refreshNum.integerValue != num) { - [self animateControlAttention:self.refreshNum]; - self.refreshNum.integerValue = num; - } - if (self.refreshUnit.indexOfSelectedItem != unit) { - [self animateControlAttention:self.refreshUnit]; - [self.refreshUnit selectItemAtIndex:unit]; - } -} - -/// Helper method to animate @c NSControl to draw user attention. View will be scalled up in a fraction of a second. -- (void)animateControlAttention:(NSView*)control { - CABasicAnimation *scale = [CABasicAnimation animationWithKeyPath:@"transform"]; - CATransform3D tr = CATransform3DIdentity; - tr = CATransform3DTranslate(tr, NSMidX(control.bounds), NSMidY(control.bounds), 0); - tr = CATransform3DScale(tr, 1.1, 1.1, 1); - tr = CATransform3DTranslate(tr, -NSMidX(control.bounds), -NSMidY(control.bounds), 0); - scale.toValue = [NSValue valueWithCATransform3D:tr]; - scale.duration = 0.15f; - scale.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseIn]; - [control.layer addAnimation:scale forKey:scale.keyPath]; + [NSDate setInterval:(Interval)sender.tag forPopup:self.refreshUnit andField:self.refreshNum animate:YES]; } diff --git a/baRSS/Preferences/Feeds Tab/OpmlExport.m b/baRSS/Preferences/Feeds Tab/OpmlExport.m index cd4c8cc..ecbeab1 100644 --- a/baRSS/Preferences/Feeds Tab/OpmlExport.m +++ b/baRSS/Preferences/Feeds Tab/OpmlExport.m @@ -64,7 +64,7 @@ [sp beginSheetModalForWindow:window completionHandler:^(NSModalResponse result) { if (result == NSModalResponseOK) { BOOL flattened = ([self radioGroupSelection:radioView] == 1); - NSArray *list = [StoreCoordinator sortedListOfRootObjectsInContext:moc]; + NSArray *list = [StoreCoordinator sortedFeedGroupsWithParent:nil inContext:moc]; NSXMLDocument *doc = [self xmlDocumentForFeeds:list hierarchical:!flattened]; NSData *xml = [doc XMLDataWithOptions:NSXMLNodePreserveAttributeOrder | NSXMLNodePrettyPrint]; NSError *error; @@ -115,12 +115,12 @@ int32_t idx = 0; if (select == 1) { // overwrite selected - for (FeedGroup *fg in [StoreCoordinator sortedListOfRootObjectsInContext:moc]) { + for (FeedGroup *fg in [StoreCoordinator sortedFeedGroupsWithParent:nil inContext:moc]) { [moc deleteObject:fg]; // Not a batch delete request to support undo } - [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:@(0)]; + [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:@0]; } else { - idx = (int32_t)[StoreCoordinator numberRootItemsInContext:moc]; + idx = (int32_t)[StoreCoordinator countRootItemsInContext:moc]; } NSMutableArray *list = [NSMutableArray array]; diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeeds.m b/baRSS/Preferences/Feeds Tab/SettingsFeeds.m index 477a799..bafcffa 100644 --- a/baRSS/Preferences/Feeds Tab/SettingsFeeds.m +++ b/baRSS/Preferences/Feeds Tab/SettingsFeeds.m @@ -61,8 +61,8 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; self.dataStore.managedObjectContext.undoManager = self.undoManager; // Register for notifications - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateIcon:) name:kNotificationFeedUpdated object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateIcon:) name:kNotificationFeedIconUpdated object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedUpdated:) name:kNotificationFeedUpdated object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedUpdated:) name:kNotificationFeedIconUpdated object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateInProgress:) name:kNotificationBackgroundUpdateInProgress object:nil]; } @@ -132,8 +132,8 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; #pragma mark - Notification callback methods -/// Callback method fired when feeds have been updated in the background. -- (void)updateIcon:(NSNotification*)notify { +/// Callback method fired when feed (or icon) has been updated in the background. +- (void)feedUpdated:(NSNotification*)notify { NSManagedObjectID *oid = notify.object; NSManagedObjectContext *moc = self.dataStore.managedObjectContext; Feed *feed = [moc objectRegisteredForID:oid]; diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeeds.xib b/baRSS/Preferences/Feeds Tab/SettingsFeeds.xib index bea7501..127856e 100644 --- a/baRSS/Preferences/Feeds Tab/SettingsFeeds.xib +++ b/baRSS/Preferences/Feeds Tab/SettingsFeeds.xib @@ -178,6 +178,7 @@ CA + diff --git a/baRSS/Preferences/General Tab/SettingsGeneral.m b/baRSS/Preferences/General Tab/SettingsGeneral.m index 9973992..72b966e 100644 --- a/baRSS/Preferences/General Tab/SettingsGeneral.m +++ b/baRSS/Preferences/General Tab/SettingsGeneral.m @@ -22,7 +22,7 @@ #import "SettingsGeneral.h" #import "AppHook.h" -#import "BarMenu.h" +#import "BarStatusItem.h" #import "UserPrefs.h" #import "StoreCoordinator.h" #import "Constants.h" @@ -62,13 +62,13 @@ - (IBAction)fixCache:(NSButton *)sender { NSUInteger deleted = [StoreCoordinator deleteUnreferenced]; - [StoreCoordinator restoreFeedCountsAndIndexPaths]; + [StoreCoordinator restoreFeedIndexPaths]; [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil]; NSLog(@"Removed %lu unreferenced core data entries.", deleted); } - (IBAction)changeMenuBarIconSetting:(NSButton*)sender { - [[(AppHook*)NSApp barMenu] updateBarIcon]; + [[(AppHook*)NSApp statusItem] updateBarIcon]; } - (IBAction)changeHttpApplication:(NSPopUpButton *)sender { diff --git a/baRSS/Preferences/General Tab/UserPrefs.h b/baRSS/Preferences/General Tab/UserPrefs.h index f50d4d5..d318f01 100644 --- a/baRSS/Preferences/General Tab/UserPrefs.h +++ b/baRSS/Preferences/General Tab/UserPrefs.h @@ -28,6 +28,7 @@ + (NSString*)getHttpApplication; + (void)setHttpApplication:(NSString*)bundleID; ++ (void)openURLsWithPreferredBrowser:(NSArray*)urls; + (NSUInteger)openFewLinksLimit; // Change with: 'defaults write de.relikd.baRSS openFewLinksLimit -int 10' + (NSUInteger)shortArticleNamesLimit; // Change with: 'defaults write de.relikd.baRSS shortArticleNamesLimit -int 50' diff --git a/baRSS/Preferences/General Tab/UserPrefs.m b/baRSS/Preferences/General Tab/UserPrefs.m index 0421776..6293f06 100644 --- a/baRSS/Preferences/General Tab/UserPrefs.m +++ b/baRSS/Preferences/General Tab/UserPrefs.m @@ -21,6 +21,7 @@ // SOFTWARE. #import "UserPrefs.h" +#import @implementation UserPrefs @@ -54,6 +55,16 @@ [[NSUserDefaults standardUserDefaults] setObject:bundleID forKey:@"defaultHttpApplication"]; } +/** + Open web links in default browser or a browser the user selected in the preferences. + + @param urls A list of @c NSURL objects that will be opened immediatelly in bulk. + */ ++ (void)openURLsWithPreferredBrowser:(NSArray*)urls { + if (urls.count == 0) return; + [[NSWorkspace sharedWorkspace] openURLs:urls withAppBundleIdentifier:[self getHttpApplication] options:NSWorkspaceLaunchDefault additionalEventParamDescriptor:nil launchIdentifiers:nil]; +} + #pragma mark - Hidden Plist Properties - /// @return The limit on how many links should be opened at the same time, if user holds the option key. diff --git a/baRSS/Status Bar Menu/BarMenu.h b/baRSS/Status Bar Menu/BarMenu.h index c0afecb..37a583c 100644 --- a/baRSS/Status Bar Menu/BarMenu.h +++ b/baRSS/Status Bar Menu/BarMenu.h @@ -22,6 +22,9 @@ #import +@class BarStatusItem; + @interface BarMenu : NSObject -- (void)updateBarIcon; +- (instancetype)init NS_UNAVAILABLE; +- (instancetype)initWithStatusItem:(BarStatusItem*)statusItem NS_DESIGNATED_INITIALIZER; @end diff --git a/baRSS/Status Bar Menu/BarMenu.m b/baRSS/Status Bar Menu/BarMenu.m index 5c39955..5a1cbc2 100644 --- a/baRSS/Status Bar Menu/BarMenu.m +++ b/baRSS/Status Bar Menu/BarMenu.m @@ -21,47 +21,31 @@ // SOFTWARE. #import "BarMenu.h" -#import "StoreCoordinator.h" -#import "FeedDownload.h" -#import "DrawImage.h" -#import "Preferences.h" -#import "UserPrefs.h" -#import "NSMenu+Ext.h" #import "Constants.h" +#import "NSMenu+Ext.h" +#import "BarStatusItem.h" +#import "MapUnreadTotal.h" +#import "StoreCoordinator.h" #import "Feed+Ext.h" -#import "FeedGroup+Ext.h" +#import "FeedArticle+Ext.h" @interface BarMenu() -@property (strong) NSStatusItem *barItem; -@property (strong) Preferences *prefWindow; -@property (assign, atomic) NSInteger unreadCountTotal; -@property (assign) BOOL coreDataEmpty; -@property (weak) NSMenu *currentOpenMenu; -@property (strong) NSArray *objectIDsForMenu; -@property (strong) NSManagedObjectContext *readContext; +@property (weak) BarStatusItem *statusItem; +@property (strong) MapUnreadTotal *unreadMap; @end @implementation BarMenu -- (instancetype)init { +- (instancetype)initWithStatusItem:(BarStatusItem*)statusItem { self = [super init]; - self.barItem = [NSStatusBar.systemStatusBar statusItemWithLength:NSVariableStatusItemLength]; - self.barItem.highlightMode = YES; - self.barItem.menu = [NSMenu menuWithDelegate:self]; - - // Unread counter - self.unreadCountTotal = 0; - [self updateBarIcon]; - [self asyncReloadUnreadCountAndUpdateBarIcon:nil]; - + self.statusItem = statusItem; + // TODO: move unread counts to status item and keep in sync when changing feeds in preferences + self.unreadMap = [[MapUnreadTotal alloc] initWithCoreData: [StoreCoordinator countAggregatedUnread]]; // Register for notifications [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedUpdated:) name:kNotificationFeedUpdated object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedIconUpdated:) name:kNotificationFeedIconUpdated object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(networkChanged:) name:kNotificationNetworkStatusChanged object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(unreadCountChanged:) name:kNotificationTotalUnreadCountChanged object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(asyncReloadUnreadCountAndUpdateBarIcon:) name:kNotificationTotalUnreadCountReset object:nil]; return self; } @@ -69,167 +53,8 @@ [[NSNotificationCenter defaultCenter] removeObserver:self]; } -#pragma mark - Update Menu Bar Icon -/** - If notification has @c object use this object to set unread count directly. - If @c object is @c nil perform core data fetch on total unread count and update icon. - */ -- (void)asyncReloadUnreadCountAndUpdateBarIcon:(NSNotification*)notify { - if (notify.object) { // set unread count directly - self.unreadCountTotal = [[notify object] integerValue]; - [self updateBarIcon]; - } else { - dispatch_async(dispatch_get_main_queue(), ^{ - self.unreadCountTotal = [StoreCoordinator unreadCountForIndexPathString:nil]; - [self updateBarIcon]; - }); - } -} - -/// Update menu bar icon and text according to unread count and user preferences. -- (void)updateBarIcon { - dispatch_async(dispatch_get_main_queue(), ^{ - if (self.unreadCountTotal > 0 && [UserPrefs defaultYES:@"globalUnreadCount"]) { - self.barItem.title = [NSString stringWithFormat:@"%ld", self.unreadCountTotal]; - } else { - self.barItem.title = @""; - } - BOOL hasNet = [FeedDownload allowNetworkConnection]; - if (self.unreadCountTotal > 0 && hasNet && [UserPrefs defaultYES:@"tintMenuBarIcon"]) { - self.barItem.image = [RSSIcon systemBarIcon:16 tint:[NSColor rssOrange] noConnection:!hasNet]; - } else { - self.barItem.image = [RSSIcon systemBarIcon:16 tint:nil noConnection:!hasNet]; - self.barItem.image.template = YES; - } - }); -} - - -#pragma mark - Notification callback methods - - -/** - Callback method fired when network conditions change. - - @param notify Notification object contains a @c BOOL value indicating the current status. - */ -- (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] integerValue]; - [self updateBarIcon]; -} - -/// Callback method fired when feeds have been updated in the background. -- (void)feedUpdated:(NSNotification*)notify { - [self updateFeed:notify.object updateIconOnly:NO]; -} - -- (void)feedIconUpdated:(NSNotification*)notify { - [self updateFeed:notify.object updateIconOnly:YES]; -} - - -#pragma mark - Rebuild menu after background feed update - - -/** - Use this method to update a single menu item and all ancestors unread count. - If the menu isn't currently open, nothing will happen. - - @param oid @c NSManagedObjectID must be a @c Feed instance object id. - */ -- (void)updateFeed:(NSManagedObjectID*)oid updateIconOnly:(BOOL)flag { - if (self.barItem.menu.numberOfItems > 0) { - // update items only if menu is already open (e.g., during background update) - NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; - Feed *feed = [moc objectWithID:oid]; - if ([feed isKindOfClass:[Feed class]]) { - NSMenu *menu = [self fixUnreadCountForSubmenus:feed]; - if (!flag) [self rebuiltFeedArticle:feed inMenu:menu]; // deepest menu level, feed items - } - [moc reset]; - } -} - -/** - Go through all parent menus and reset the menu title and unread count - - @return @c NSMenu containing @c FeedArticle. Will be @c nil if user hasn't open the menu yet. - */ -- (nullable NSMenu*)fixUnreadCountForSubmenus:(Feed*)feed { - NSMenu *menu = self.barItem.menu; - [menu autoEnableMenuHeader:(self.unreadCountTotal > 0)]; - for (FeedGroup *parent in [feed.group allParents]) { - NSInteger itemIndex = [menu feedDataOffset] + parent.sortIndex; - NSMenuItem *item = [menu itemAtIndex:itemIndex]; - NSInteger unread = [item setTitleAndUnreadCount:parent]; - menu = item.submenu; - - if (parent == feed.group) { - // Always set icon. Will flip warning icon to default icon if article count changes. - item.image = [feed iconImage16]; - item.enabled = (feed.articles.count > 0); - return menu; - } - - if (!menu || menu.numberOfItems == 0) - return nil; - if (unread == 0) // if != 0 then 'setTitleAndUnreadCount' was successful (UserPrefs visible) - unread = [menu coreDataUnreadCount]; - [menu autoEnableMenuHeader:(unread > 0)]; // of submenu but not articles menu (will be rebuild anyway) - } - return nil; -} - -/** - Remove all @c NSMenuItem in menu and generate new ones. items from @c feed.items. - - @param feed Corresponding @c Feed to @c NSMenu. - @param menu Deepest menu level which contains only feed items. - */ -- (void)rebuiltFeedArticle:(Feed*)feed inMenu:(NSMenu*)menu { - if (!menu || menu.numberOfItems == 0) // not opened yet - return; - if (self.currentOpenMenu != menu) { - // if the menu isn't open, re-create it dynamically instead - menu.itemArray.firstObject.parentItem.submenu = [menu cleanInstanceCopy]; - } else { - [menu removeAllItems]; - [self insertDefaultHeaderForAllMenus:menu hasUnread:(feed.unreadCount > 0)]; - for (FeedArticle *fa in [feed sortedArticles]) { - NSMenuItem *mi = [menu addItemWithTitle:@"" action:@selector(openFeedURL:) keyEquivalent:@""]; - mi.target = self; - [mi setFeedArticle:fa]; - } - } -} - - -#pragma mark - Menu Delegate & Menu Generation - - -/// @c currentOpenMenu is needed when a background update occurs. In case a feed items menu is open. -- (void)menuWillOpen:(NSMenu *)menu { - self.currentOpenMenu = menu; -} - -/// Get rid of everything that is not needed when the system bar menu is closed. -- (void)menuDidClose:(NSMenu*)menu { - self.currentOpenMenu = nil; - if ([menu isMainMenu]) - self.barItem.menu = [NSMenu menuWithDelegate:self]; -} +#pragma mark - Generate Menu Items /** @note Delegate method not used. Here to prevent weird @c NSMenu behavior. @@ -240,246 +65,110 @@ return NO; } -/// Perform a core data fatch request, store sorted object ids array and return object count. -- (NSInteger)numberOfItemsInMenu:(NSMenu*)menu { - [self prepareContextAndTemporaryObjectIDs:menu]; - if (_coreDataEmpty) return 1; // only if main menu empty - return (NSInteger)self.objectIDsForMenu.count; -} - -/// Lazy populate system bar menus when needed. -- (BOOL)menu:(NSMenu*)menu updateItem:(NSMenuItem*)item atIndex:(NSInteger)index shouldCancel:(BOOL)shouldCancel { - if (_coreDataEmpty) { - item.title = NSLocalizedString(@"~~~ list empty ~~~", nil); - item.enabled = NO; - [self finalizeMenu:menu object:nil]; - return YES; - } - id obj = [self.readContext objectWithID:[self.objectIDsForMenu objectAtIndex:(NSUInteger)index]]; - if ([obj isKindOfClass:[FeedGroup class]]) { - [item setFeedGroup:obj]; - if ([(FeedGroup*)obj type] == FEED) - [item setTarget:self action:@selector(openFeedURL:)]; - } else if ([obj isKindOfClass:[FeedArticle class]]) { - [item setFeedArticle:obj]; - [item setTarget:self action:@selector(openFeedURL:)]; - } - - if (index + 1 == menu.numberOfItems) { // last item of the menu - [self finalizeMenu:menu object:obj]; - [self resetContextAndTemporaryObjectIDs]; - } - return YES; -} - - -#pragma mark - Helper - - -- (void)prepareContextAndTemporaryObjectIDs:(NSMenu*)menu { - NSMenuItem *parent = [menu.supermenu itemAtIndex:[menu.supermenu indexOfItemWithSubmenu:menu]]; - self.readContext = [StoreCoordinator createChildContext]; // will be deleted after menu:updateItem: - self.objectIDsForMenu = [StoreCoordinator sortedObjectIDsForParent:parent.representedObject isFeed:[menu isFeedMenu] inContext:self.readContext]; - _coreDataEmpty = ([menu isMainMenu] && self.objectIDsForMenu.count == 0); // initial state or no feeds in date store -} - -- (void)resetContextAndTemporaryObjectIDs { - self.objectIDsForMenu = nil; - [self.readContext reset]; - self.readContext = nil; -} - -/** - Add default menu items that are present in each menu as header and disable menu items if necessary - */ -- (void)finalizeMenu:(NSMenu*)menu object:(id)obj { - NSInteger unreadCount = self.unreadCountTotal; // if parent == nil - if ([menu isFeedMenu]) { - unreadCount = ((FeedArticle*)obj).feed.unreadCount; - } else if (![menu isMainMenu]) { - unreadCount = [menu coreDataUnreadCount]; - } - [menu replaceSeparatorStringsWithActualSeparator]; - [self insertDefaultHeaderForAllMenus:menu hasUnread:(unreadCount > 0)]; - if ([menu 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 hasUnread:(BOOL)flag { - MenuItemTag scope = [menu scope]; - NSMenuItem *item1 = [NSMenuItem itemWithTitle:NSLocalizedString(@"Open all unread", nil) - action:@selector(openAllUnread:) target:self tag:TagOpenAllUnread | scope]; - NSMenuItem *item2 = [item1 alternateWithTitle:[NSString stringWithFormat:@"%@ (%lu)", - NSLocalizedString(@"Open a few unread", nil), [UserPrefs openFewLinksLimit]]]; - NSMenuItem *item3 = [NSMenuItem itemWithTitle:NSLocalizedString(@"Mark all read", nil) - action:@selector(markAllReadOrUnread:) target:self tag:TagMarkAllRead | scope]; - NSMenuItem *item4 = [NSMenuItem itemWithTitle:NSLocalizedString(@"Mark all unread", nil) - action:@selector(markAllReadOrUnread:) target:self 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 = [NSMenuItem itemWithTitle:@"" action:@selector(pauseUpdates:) target:self tag:TagPauseUpdates]; - NSMenuItem *item2 = [NSMenuItem itemWithTitle:NSLocalizedString(@"Update all feeds", nil) - action:@selector(updateAllFeeds:) target:self tag:TagUpdateFeed]; - item1.title = ([FeedDownload isPaused] ? - NSLocalizedString(@"Resume Updates", nil) : NSLocalizedString(@"Pause Updates", nil)); - if ([UserPrefs defaultYES:@"globalUpdateAll"] == NO) - item2.hidden = YES; - if (![FeedDownload allowNetworkConnection]) - 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 = [NSMenuItem itemWithTitle:NSLocalizedString(@"Preferences", nil) - action:@selector(openPreferences) target:self tag:TagPreferences]; - prefs.keyEquivalent = @","; - [menu addItem:prefs]; - [menu addItemWithTitle:NSLocalizedString(@"Quit", nil) action:@selector(terminate:) keyEquivalent:@"q"]; -} - - -#pragma mark - Menu Actions - - -/** - Called whenever the user activates the preferences (either through menu click or hotkey) - */ -- (void)openPreferences { - if (!self.prefWindow) { - self.prefWindow = [[Preferences alloc] initWithWindowNibName:@"Preferences"]; - self.prefWindow.window.title = [NSString stringWithFormat:@"%@ %@", NSProcessInfo.processInfo.processName, - NSLocalizedString(@"Preferences", nil)]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(preferencesClosed:) name:NSWindowWillCloseNotification object:self.prefWindow.window]; - } - [NSApp activateIgnoringOtherApps:YES]; - [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; - [FeedDownload scheduleUpdateForUpcomingFeeds]; -} - -/** - Called when user clicks on 'Pause Updates' in the main menu (only). - */ -- (void)pauseUpdates:(NSMenuItem*)sender { - [FeedDownload setPaused:![FeedDownload isPaused]]; - [self updateBarIcon]; -} - -/** - Called when user clicks on 'Update all feeds' in the main menu (only). - */ -- (void)updateAllFeeds:(NSMenuItem*)sender { - [self asyncReloadUnreadCountAndUpdateBarIcon:nil]; - [FeedDownload forceUpdateAllFeeds]; -} - -/** - Called when user clicks on 'Open all unread' or 'Open a few unread ...' on any scope level. - */ -- (void)openAllUnread:(NSMenuItem*)sender { - NSMutableArray *urls = [NSMutableArray array]; - __block int maxItemCount = INT_MAX; - if (sender.isAlternate) - maxItemCount = (int)[UserPrefs openFewLinksLimit]; - +/// Populate menu with items. +- (void)menuNeedsUpdate:(NSMenu*)menu { NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; - [sender iterateSorted:YES inContext:moc overDescendentFeeds:^(Feed *feed, BOOL *cancel) { - for (FeedArticle *fa in [feed sortedArticles]) { // TODO: open oldest articles first? - if (maxItemCount <= 0) break; - if (fa.unread && fa.link.length > 0) { - [urls addObject:[NSURL URLWithString:fa.link]]; - fa.unread = NO; - feed.unreadCount -= 1; - self.unreadCountTotal -= 1; - maxItemCount -= 1; + if (menu.isFeedMenu) { + Feed *feed = [StoreCoordinator feedWithIndexPath:menu.titleIndexPath inContext:moc]; + [self setArticles:[feed sortedArticles] forMenu:menu]; + } else { + NSArray *groups = [StoreCoordinator sortedFeedGroupsWithParent:menu.parentItem.representedObject inContext:moc]; + if (groups.count == 0) { + [menu addItemWithTitle:NSLocalizedString(@"~~~ no entries ~~~", nil) action:nil keyEquivalent:@""].enabled = NO; + } else { + [self setFeedGroups:groups forMenu:menu]; + } + } + [moc reset]; +} + +/// Get rid of everything that is not needed. +- (void)menuDidClose:(NSMenu*)menu { + [menu cleanup]; +} + +/// Generate items for @c FeedGroup menu. +- (void)setFeedGroups:(NSArray*)sortedList forMenu:(NSMenu*)menu { + [menu insertDefaultHeader]; + for (FeedGroup *fg in sortedList) { + [menu insertFeedGroupItem:fg].submenu.delegate = self; + } + UnreadTotal *uct = self.unreadMap[menu.titleIndexPath]; + [menu setHeaderHasUnread:(uct.unread > 0) hasRead:(uct.unread < uct.total)]; + // set unread counts + for (NSMenuItem *item in menu.itemArray) { + if (item.hasSubmenu) + [item setTitleCount:self.unreadMap[item.submenu.titleIndexPath].unread]; + } +} + +/// Generate items for @c FeedArticles menu. +- (void)setArticles:(NSArray*)sortedList forMenu:(NSMenu*)menu { + [menu insertDefaultHeader]; + for (FeedArticle *fa in sortedList) { + [menu addItem:[fa newMenuItem]]; + } + UnreadTotal *uct = self.unreadMap[menu.titleIndexPath]; + [menu setHeaderHasUnread:(uct.unread > 0) hasRead:(uct.unread < uct.total)]; +} + + +#pragma mark - Background Update / Rebuild Menu + +/** + Fetch @c Feed from core data and find deepest visible @c NSMenuItem. + @warning @c item and @c feed will often mismatch. + */ +- (void)updateFeedMenuItem:(NSManagedObjectID*)oid withBlock:(void(^)(Feed *feed, NSMenuItem *item))block { + NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; + Feed *feed = [moc objectWithID:oid]; + if (![feed isKindOfClass:[Feed class]]) { + [moc reset]; + return; + } + NSMenuItem *item = [self.statusItem.mainMenu deepestItemWithPath:feed.indexPath]; + if (!item) { + [moc reset]; + return; + } + block(feed, item); + [moc reset]; +} + +/// Callback method fired when feed has been updated in the background. +- (void)feedUpdated:(NSNotification*)notify { + [self updateFeedMenuItem:notify.object withBlock:^(Feed *feed, NSMenuItem *item) { + // 1. update in-memory unread count + UnreadTotal *updated = [UnreadTotal new]; + updated.total = feed.articles.count; + for (FeedArticle *fa in feed.articles) { + if (fa.unread) updated.unread += 1; + } + [self.unreadMap updateAllCounts:updated forPath:feed.indexPath]; + // 2. rebuild articles menu if it is open + if (item.submenu.isFeedMenu) { // menu item is visible + item.enabled = (feed.articles.count > 0); + if (item.submenu.numberOfItems > 0) { // replace articles menu + [item.submenu removeAllItems]; + [self setArticles:[feed sortedArticles] forMenu:item.submenu]; } } - *cancel = (maxItemCount <= 0); - }]; - [self updateBarIcon]; - [self openURLsWithPreferredBrowser:urls]; - [StoreCoordinator saveContext:moc andParent:YES]; - [moc reset]; -} - -/** - Called when user clicks on 'Mark all read' @b or 'Mark all unread' on any scope level. - */ -- (void)markAllReadOrUnread:(NSMenuItem*)sender { - BOOL markRead = ((sender.tag & TagMaskType) == TagMarkAllRead); - NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; - [sender iterateSorted:NO inContext:moc overDescendentFeeds:^(Feed *feed, BOOL *cancel) { - self.unreadCountTotal += (markRead ? [feed markAllItemsRead] : [feed markAllItemsUnread]); - }]; - [self updateBarIcon]; - [StoreCoordinator saveContext:moc andParent:YES]; - [moc reset]; -} - -/** - Called when user clicks on a single feed item or the feed group. - - @param sender A menu item containing either a @c FeedArticle or a @c FeedGroup objectID. - */ -- (void)openFeedURL:(NSMenuItem*)sender { - NSManagedObjectID *oid = sender.representedObject; - if (!oid) - return; - NSString *url = nil; - NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; - id obj = [moc objectWithID:oid]; - if ([obj isKindOfClass:[FeedGroup class]]) { - url = ((FeedGroup*)obj).feed.link; - } else if ([obj isKindOfClass:[FeedArticle class]]) { - FeedArticle *fa = obj; - url = fa.link; - if (fa.unread) { - fa.unread = NO; - fa.feed.unreadCount -= 1; - self.unreadCountTotal -= 1; - [self updateBarIcon]; - [StoreCoordinator saveContext:moc andParent:YES]; + // 3. set unread count & enabled header for all parents + NSArray *itms = [self.unreadMap itemsForPath:item.submenu.titleIndexPath create:NO]; + for (UnreadTotal *uct in itms.reverseObjectEnumerator) { + [item.submenu setHeaderHasUnread:(uct.unread > 0) hasRead:(uct.unread < uct.total)]; + [item setTitleCount:uct.unread]; + item = item.parentItem; } - } - [moc reset]; - if (!url || url.length == 0) return; - [self openURLsWithPreferredBrowser:@[[NSURL URLWithString:url]]]; + }]; } -/** - Open web links in default browser or a browser the user selected in the preferences. - - @param urls A list of @c NSURL objects that will be opened immediatelly in bulk. - */ -- (void)openURLsWithPreferredBrowser:(NSArray*)urls { - if (urls.count == 0) return; - [[NSWorkspace sharedWorkspace] openURLs:urls withAppBundleIdentifier:[UserPrefs getHttpApplication] options:NSWorkspaceLaunchDefault additionalEventParamDescriptor:nil launchIdentifiers:nil]; +/// Callback method fired when feed icon has changed. +- (void)feedIconUpdated:(NSNotification*)notify { + [self updateFeedMenuItem:notify.object withBlock:^(Feed *feed, NSMenuItem *item) { + if (item.submenu.isFeedMenu) + item.image = [feed iconImage16]; + }]; } @end diff --git a/baRSS/Status Bar Menu/BarStatusItem.h b/baRSS/Status Bar Menu/BarStatusItem.h new file mode 100644 index 0000000..15d63ec --- /dev/null +++ b/baRSS/Status Bar Menu/BarStatusItem.h @@ -0,0 +1,33 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 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 + +@interface BarStatusItem : NSObject +@property (weak, readonly) NSMenu *mainMenu; + +- (void)setUnreadCountAbsolute:(NSUInteger)count; +- (void)setUnreadCountRelative:(NSInteger)count; +- (void)asyncReloadUnreadCount; +- (void)updateBarIcon; +@end + diff --git a/baRSS/Status Bar Menu/BarStatusItem.m b/baRSS/Status Bar Menu/BarStatusItem.m new file mode 100644 index 0000000..6b23bd4 --- /dev/null +++ b/baRSS/Status Bar Menu/BarStatusItem.m @@ -0,0 +1,182 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 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 "BarStatusItem.h" +#import "Constants.h" +#import "DrawImage.h" +#import "FeedDownload.h" +#import "StoreCoordinator.h" +#import "UserPrefs.h" +#import "BarMenu.h" +#import "AppHook.h" + +@interface BarStatusItem() +@property (strong) BarMenu *barMenu; +@property (strong) NSStatusItem *statusItem; +@property (assign) NSInteger unreadCountTotal; +@property (weak) NSMenuItem *updateAllItem; +@end + +@implementation BarStatusItem + +- (NSMenu *)mainMenu { return _statusItem.menu; } + +- (instancetype)init { + self = [super init]; + // Show icon & prefetch unread count + self.statusItem = [NSStatusBar.systemStatusBar statusItemWithLength:NSVariableStatusItemLength]; + self.statusItem.highlightMode = YES; + self.unreadCountTotal = 0; + [self updateBarIcon]; + [self asyncReloadUnreadCount]; + // Add empty menu (will be populated once opened) + self.statusItem.menu = [[NSMenu alloc] initWithTitle:@"M"]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(mainMenuWillOpen) name:NSMenuDidBeginTrackingNotification object:self.statusItem.menu]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(mainMenuDidClose) name:NSMenuDidEndTrackingNotification object:self.statusItem.menu]; + // Some icon unread count notification callback methods + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(networkChanged:) name:kNotificationNetworkStatusChanged object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(unreadCountChanged:) name:kNotificationTotalUnreadCountChanged object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(unreadCountReset:) name:kNotificationTotalUnreadCountReset object:nil]; + return self; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + + +#pragma mark - Notification Center Callback Methods + +/// Fired when network conditions change. +- (void)networkChanged:(NSNotification*)notify { + BOOL available = [[notify object] boolValue]; + self.updateAllItem.enabled = available; + [self updateBarIcon]; +} + +/// Fired when a single feed has been updated. Object contains relative unread count change. +- (void)unreadCountChanged:(NSNotification*)notify { + [self setUnreadCountRelative:[[notify object] integerValue]]; +} + +/** + If notification has @c object use this object to set unread count directly. + If @c object is @c nil perform core data fetch on total unread count and update icon. + */ +- (void)unreadCountReset:(NSNotification*)notify { + if (notify.object) // set unread count directly + [self setUnreadCountAbsolute:[[notify object] unsignedIntegerValue]]; + else + [self asyncReloadUnreadCount]; +} + + +#pragma mark - Helper + +/// Assign total unread count value directly. +- (void)setUnreadCountAbsolute:(NSUInteger)count { + _unreadCountTotal = (NSInteger)count; + [self updateBarIcon]; +} + +/// Assign new value by adding @c count to total unread count (may be negative). +- (void)setUnreadCountRelative:(NSInteger)count { + _unreadCountTotal += count; + [self updateBarIcon]; +} + +/// Fetch new total unread count from core data and assign it as new value (dispatch async on main thread). +- (void)asyncReloadUnreadCount { + dispatch_async(dispatch_get_main_queue(), ^{ + [self setUnreadCountAbsolute:[StoreCoordinator countTotalUnread]]; + }); +} + + +#pragma mark - Update Menu Bar Icon + +/// Update menu bar icon and text according to unread count and user preferences. +- (void)updateBarIcon { + dispatch_async(dispatch_get_main_queue(), ^{ + if (self.unreadCountTotal > 0 && [UserPrefs defaultYES:@"globalUnreadCount"]) { + self.statusItem.title = [NSString stringWithFormat:@"%ld", self.unreadCountTotal]; + } else { + self.statusItem.title = @""; + } + BOOL hasNet = [FeedDownload allowNetworkConnection]; + if (self.unreadCountTotal > 0 && hasNet && [UserPrefs defaultYES:@"tintMenuBarIcon"]) { + self.statusItem.image = [RSSIcon systemBarIcon:16 tint:[NSColor rssOrange] noConnection:!hasNet]; + } else { + self.statusItem.image = [RSSIcon systemBarIcon:16 tint:nil noConnection:!hasNet]; + self.statusItem.image.template = YES; + } + }); +} + + +#pragma mark - Main Menu Handling + +- (void)mainMenuWillOpen { + self.barMenu = [[BarMenu alloc] initWithStatusItem:self]; + [self insertMainMenuHeader:self.statusItem.menu]; + [self.barMenu menuNeedsUpdate:self.statusItem.menu]; + // Add main menu items 'Preferences' and 'Quit'. + [self.statusItem.menu addItem:[NSMenuItem separatorItem]]; + [self.statusItem.menu addItemWithTitle:NSLocalizedString(@"Preferences", nil) action:@selector(openPreferences) keyEquivalent:@","]; + [self.statusItem.menu addItemWithTitle:NSLocalizedString(@"Quit", nil) action:@selector(terminate:) keyEquivalent:@"q"]; +} + +- (void)mainMenuDidClose { + [self.statusItem.menu removeAllItems]; + self.barMenu = nil; +} + +- (void)insertMainMenuHeader:(NSMenu*)menu { + // 'Pause Updates' item + NSMenuItem *pause = [menu addItemWithTitle:NSLocalizedString(@"Pause Updates", nil) action:@selector(pauseUpdates) keyEquivalent:@""]; + pause.target = self; + if ([FeedDownload isPaused]) + pause.title = NSLocalizedString(@"Resume Updates", nil); + // 'Update all feeds' item + if ([UserPrefs defaultYES:@"globalUpdateAll"]) { + NSMenuItem *updateAll = [menu addItemWithTitle:NSLocalizedString(@"Update all feeds", nil) action:@selector(updateAllFeeds) keyEquivalent:@""]; + updateAll.target = self; + updateAll.enabled = [FeedDownload allowNetworkConnection]; + self.updateAllItem = updateAll; + } + // Separator between main header and default header + [menu addItem:[NSMenuItem separatorItem]]; +} + +/// Called when user clicks on 'Pause Updates' (main menu only). +- (void)pauseUpdates { + [FeedDownload setPaused:![FeedDownload isPaused]]; + [self updateBarIcon]; +} + +/// Called when user clicks on 'Update all feeds' (main menu only). +- (void)updateAllFeeds { +// [self asyncReloadUnreadCount]; // should not be necessary + [FeedDownload forceUpdateAllFeeds]; +} + +@end diff --git a/baRSS/Status Bar Menu/MapUnreadTotal.h b/baRSS/Status Bar Menu/MapUnreadTotal.h new file mode 100644 index 0000000..18eeb20 --- /dev/null +++ b/baRSS/Status Bar Menu/MapUnreadTotal.h @@ -0,0 +1,41 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 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 + +@interface UnreadTotal : NSObject +@property (nonatomic, assign) NSUInteger unread; +@property (nonatomic, assign) NSUInteger total; +@end + + +@interface MapUnreadTotal : NSObject +- (instancetype)init NS_UNAVAILABLE; +- (instancetype)initWithCoreData:(NSArray*)data NS_DESIGNATED_INITIALIZER; + +- (NSArray*)itemsForPath:(NSString*)path create:(BOOL)flag; +- (void)updateAllCounts:(UnreadTotal*)updated forPath:(NSString*)path; + +// Keyed subscription +- (UnreadTotal*)objectForKeyedSubscript:(NSString*)key; +- (void)setObject:(UnreadTotal*)obj forKeyedSubscript:(NSString*)key; +@end diff --git a/baRSS/Status Bar Menu/MapUnreadTotal.m b/baRSS/Status Bar Menu/MapUnreadTotal.m new file mode 100644 index 0000000..3dc2699 --- /dev/null +++ b/baRSS/Status Bar Menu/MapUnreadTotal.m @@ -0,0 +1,94 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 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 "MapUnreadTotal.h" + +@interface MapUnreadTotal() +@property (strong) NSMutableDictionary *map; +@end + +@implementation MapUnreadTotal + +- (NSString *)description { return _map.description; } +- (UnreadTotal*)objectForKeyedSubscript:(NSString*)key { return _map[key]; } +- (void)setObject:(UnreadTotal*)obj forKeyedSubscript:(NSString*)key { _map[key] = obj; } + +/// Perform core data fetch and sum unread counts per @c Feed. Aggregate counts that are grouped in @c FeedGroup. +- (instancetype)initWithCoreData:(NSArray*)data { + self = [super init]; + if (self) { + UnreadTotal *sum = [UnreadTotal new]; + _map = [NSMutableDictionary dictionaryWithCapacity:data.count]; + _map[@""] = sum; + + for (NSDictionary *d in data) { + NSUInteger u = [d[@"unread"] unsignedIntegerValue]; + NSUInteger t = [d[@"total"] unsignedIntegerValue]; + sum.unread += u; + sum.total += t; + + for (UnreadTotal *uct in [self itemsForPath:d[@"indexPath"] create:YES]) { + uct.unread += u; + uct.total += t; + } + } + } + return self; +} + +/// @return All group items and deepest item of @c path. If @c flag @c = @c YES non-existing items will be created. +- (NSArray*)itemsForPath:(NSString*)path create:(BOOL)flag { + NSMutableArray *arr = [NSMutableArray array]; + NSMutableString *key = [NSMutableString string]; + for (NSString *idx in [path componentsSeparatedByString:@"."]) { + if (key.length > 0) + [key appendString:@"."]; + [key appendString:idx]; + + UnreadTotal *a = _map[key]; + if (!a) { + if (!flag) continue; // skip item creation if flag = NO + a = [UnreadTotal new]; + _map[key] = a; + } + [arr addObject:a]; + } + return arr; +} + +/// Set new values for item at @c path. Updating all group items as well. +- (void)updateAllCounts:(UnreadTotal*)updated forPath:(NSString*)path { + UnreadTotal *previous = _map[path]; + NSUInteger diffU = (updated.unread - previous.unread); + NSUInteger diffT = (updated.total - previous.total); + for (UnreadTotal *uct in [self itemsForPath:path create:NO]) { + uct.unread += diffU; + uct.total += diffT; + } +} + +@end + + +@implementation UnreadTotal +- (NSString *)description { return [NSString stringWithFormat:@"", _unread, _total]; } +@end diff --git a/baRSS/Status Bar Menu/NSMenu+Ext.h b/baRSS/Status Bar Menu/NSMenu+Ext.h index 6ac3afd..aecab4b 100644 --- a/baRSS/Status Bar Menu/NSMenu+Ext.h +++ b/baRSS/Status Bar Menu/NSMenu+Ext.h @@ -21,20 +21,26 @@ // SOFTWARE. #import -#import "NSMenuItem+Ext.h" + +@class FeedGroup; @interface NSMenu (Ext) +@property (nonnull, copy, readonly) NSString *titleIndexPath; +@property (nullable, readonly) NSMenuItem* parentItem; +@property (readonly) BOOL isMainMenu; +@property (readonly) BOOL isFeedMenu; + // Generator -+ (instancetype)menuWithDelegate:(id)target; -- (instancetype)submenuWithIndex:(int)index isFeed:(BOOL)flag; -- (instancetype)cleanInstanceCopy; -// Properties -- (BOOL)isMainMenu; -- (BOOL)isFeedMenu; -- (MenuItemTag)scope; -- (NSInteger)feedDataOffset; -- (NSInteger)coreDataUnreadCount; -// Modify menu -- (void)replaceSeparatorStringsWithActualSeparator; -- (void)autoEnableMenuHeader:(BOOL)hasUnread; +- (NSMenuItem*)insertFeedGroupItem:(FeedGroup*)fg; +- (void)insertDefaultHeader; +// Update menu +- (void)cleanup; +- (void)setHeaderHasUnread:(BOOL)hasUnread hasRead:(BOOL)hasRead; +- (NSMenuItem*)deepestItemWithPath:(nonnull NSString*)path; +@end + + +@interface NSMenuItem (Ext) +- (instancetype)alternateWithTitle:(NSString*)title; +- (void)setTitleCount:(NSUInteger)count; @end diff --git a/baRSS/Status Bar Menu/NSMenu+Ext.m b/baRSS/Status Bar Menu/NSMenu+Ext.m index 9c23d1f..9dedd0b 100644 --- a/baRSS/Status Bar Menu/NSMenu+Ext.m +++ b/baRSS/Status Bar Menu/NSMenu+Ext.m @@ -22,101 +22,225 @@ #import "NSMenu+Ext.h" #import "StoreCoordinator.h" +#import "UserPrefs.h" +#import "Feed+Ext.h" +#import "FeedGroup+Ext.h" +#import "Constants.h" + +typedef NS_ENUM(NSInteger, MenuItemTag) { + /// Used in @c allowDisplayOfHeaderItem: to identify and enable items + TagMarkAllRead = 1, + TagMarkAllUnread = 2, + TagOpenAllUnread = 3, + /// Delimiter item between default header and core data items + TagHeaderDelimiter = 8, + /// Indicator whether unread count is currently shown in menu item title or not + TagTitleCountVisible = 16, +}; + @implementation NSMenu (Ext) -#pragma mark - Generator - - -/// @return New main menu with target delegate. -+ (instancetype)menuWithDelegate:(id)target { - NSMenu *menu = [[NSMenu alloc] initWithTitle:@"M"]; - menu.autoenablesItems = NO; - menu.delegate = target; - return menu; -} - -/// @return New menu with old title and delegate. Index path in title is appended. -- (instancetype)submenuWithIndex:(int)index isFeed:(BOOL)flag { - NSMenu *menu = [NSMenu menuWithDelegate:self.delegate]; - menu.title = [NSString stringWithFormat:@"%c%@.%d", (flag ? 'F' : 'G'), self.title, index]; - return menu; -} - -/// @return New menu with old title and delegate. -- (instancetype)cleanInstanceCopy { - NSMenu *menu = [NSMenu menuWithDelegate:self.delegate]; - menu.title = self.title; - return menu; -} - - #pragma mark - Properties - +/// @return Dot separated list of @c sortIndex of each @c FeedGroup parent. Empty string if main menu. +- (NSString*)titleIndexPath { + if (self.title.length <= 2) return @""; + return [self.title substringFromIndex:2]; +} + +/// @return The menu item in the super menu. Or @c nil if there is no super menu. +- (NSMenuItem*)parentItem { + if (!self.supermenu) return nil; + //return self.itemArray.firstObject.parentItem; // wont work without items + //return self.supermenu.highlightedItem; // is highlight guaranteed? + return [self.supermenu itemAtIndex:[self.supermenu indexOfItemWithSubmenu:self]]; +} /// @return @c YES if menu is status bar menu. -- (BOOL)isMainMenu { - return [self.title isEqualToString:@"M"]; -} +- (BOOL)isMainMenu { return (self.supermenu == nil); } /// @return @c YES if menu contains feed articles only. -- (BOOL)isFeedMenu { - return [self.title characterAtIndex:0] == 'F'; -} +- (BOOL)isFeedMenu { return ([self.title characterAtIndex:0] == 'F'); } -/// @return Either @c ScopeGlobal, @c ScopeGroup or @c ScopeFeed. -- (MenuItemTag)scope { - if ([self isFeedMenu]) return ScopeFeed; - if ([self isMainMenu]) return ScopeGlobal; - return ScopeGroup; -} -/// @return Index offset of the first core data feed item (may be separator), skipping default header and main menu header. -- (NSInteger)feedDataOffset { - for (NSInteger i = 0; i < self.numberOfItems; i++) { - if ([[[self itemAtIndex:i] representedObject] isKindOfClass:[NSManagedObjectID class]]) - return i; +#pragma mark - Generator - + +/// Create new @c NSMenuItem with empty submenu and append it to the menu. @return Inserted item. +- (NSMenuItem*)insertFeedGroupItem:(FeedGroup*)fg { + unichar chr = '-'; + NSMenuItem *item = nil; + switch (fg.type) { + case GROUP: item = [fg newMenuItem]; chr = 'G'; break; + case FEED: item = [fg.feed newMenuItem]; chr = 'F'; break; + case SEPARATOR: item = [NSMenuItem separatorItem]; break; } - return 0; + if (!item.isSeparatorItem) { + NSString *t = [NSString stringWithFormat:@"%c%@.%d", chr, [self.title substringFromIndex:1], fg.sortIndex]; + item.submenu = [[NSMenu alloc] initWithTitle:t]; + } + [self addItem:item]; + return item; } -/// Perform core data fetch request and return unread count for all descendent items. -- (NSInteger)coreDataUnreadCount { - NSUInteger loc = [self.title rangeOfString:@"."].location; - NSString *path = nil; - if (loc != NSNotFound) - path = [self.title substringFromIndex:loc + 1]; - return [StoreCoordinator unreadCountForIndexPathString:path]; +/// Insert items 'Open all unread', 'Mark all read' and 'Mark all unread'. +- (void)insertDefaultHeader { + self.autoenablesItems = NO; + NSMenuItem *itm = [self addItemIfAllowed:TagOpenAllUnread title:NSLocalizedString(@"Open all unread", nil)]; + if (itm) { + [self addItem:[itm alternateWithTitle:[NSString stringWithFormat:NSLocalizedString(@"Open a few unread (%lu)", nil), [UserPrefs openFewLinksLimit]]]]; + } + [self addItemIfAllowed:TagMarkAllRead title:NSLocalizedString(@"Mark all read", nil)]; + [self addItemIfAllowed:TagMarkAllUnread title:NSLocalizedString(@"Mark all unread", nil)]; + if (self.numberOfItems > 0) { + // in case someone has disabled all header items. Else, during articles menu rebuild it will stay on top. + NSMenuItem *sep = [NSMenuItem separatorItem]; + sep.tag = TagHeaderDelimiter; + [self addItem:sep]; + } } -#pragma mark - Modify Menu - +#pragma mark - Update Menu +/// Replace this menu with a clean @c NSMenu. Copy old @c title and @c delegate to new menu. @b Won't work without supermenu! +- (void)cleanup { + NSMenu *m = [[NSMenu alloc] initWithTitle:self.title]; + m.delegate = self.delegate; + self.parentItem.submenu = m; +} /// Loop over default header and enable 'OpenAllUnread' and 'TagMarkAllRead' based on unread count. -- (void)autoEnableMenuHeader:(BOOL)hasUnread { - for (NSMenuItem *item in self.itemArray) { - if (item.representedObject) - return; // default menu has no represented object - switch (item.tag & TagMaskType) { - case TagOpenAllUnread: case TagMarkAllRead: - item.enabled = hasUnread; - default: break; +- (void)setHeaderHasUnread:(BOOL)hasUnread hasRead:(BOOL)hasRead { + NSInteger i = [self indexOfItemWithTag:TagHeaderDelimiter] - 1; + for (; i >= 0; i--) { + NSMenuItem *item = [self itemAtIndex:i]; + switch (item.tag) { + case TagOpenAllUnread: // incl. alternate item + case TagMarkAllRead: + item.enabled = hasUnread; break; + case TagMarkAllUnread: + item.enabled = hasRead; break; } - //[item applyUserSettingsDisplay]; // should not change while menu is open } } -/// Loop over menu and replace all separator items (text) with actual separator. -- (void)replaceSeparatorStringsWithActualSeparator { - for (NSInteger i = 0; i < self.numberOfItems; i++) { - NSMenuItem *oldItem = [self itemAtIndex:i]; - if ([oldItem.title isEqualToString:kSeparatorItemTitle]) { - NSMenuItem *newItem = [NSMenuItem separatorItem]; - newItem.representedObject = oldItem.representedObject; - [self removeItemAtIndex:i]; - [self insertItem:newItem atIndex:i]; +/** + Iterate over all menu items in @c self.itemArray and find the item where @c submenu.title matches + the first @c sortIndex in @c path. Recursively repeat the process for the items of this submenu and so on. + + @param path Dot separated list of @c sortIndex. E.g., @c Feed.indexPath. + @return Either @c NSMenuItem that exactly matches @c path or one of the parent @c NSMenuItem if a submenu isn't open. + */ +- (NSMenuItem*)deepestItemWithPath:(nonnull NSString*)path { + NSUInteger loc = [path rangeOfString:@"."].location; + BOOL isLast = (loc == NSNotFound); + NSString *indexStr = (isLast ? path : [path substringToIndex:loc]); + for (NSMenuItem *item in self.itemArray) { + if (item.hasSubmenu && [item.submenu.title hasSuffix:indexStr]) { + if (!isLast && item.submenu.numberOfItems > 0) + return [item.submenu deepestItemWithPath:[path substringFromIndex:loc+1]]; + return item; } } + return nil; +} + + +#pragma mark - Helper + +/// Check user preferences for preferred display style. +- (BOOL)allowDisplayOfHeaderItem:(MenuItemTag)tag { + static const char * A[] = {"", "global", "feed", "group"}; + static const char * B[] = {"", "MarkRead", "MarkUnread", "OpenUnread"}; + int idx = (self.isMainMenu ? 1 : (self.isFeedMenu ? 2 : 3)); + return [UserPrefs defaultYES:[NSString stringWithFormat:@"%s%s", A[idx], B[tag & 3]]]; // first 2 bits +} + +/// Check user preferences if item should be displayed in menu. If so, add it to the menu and set callback to @c self. +- (NSMenuItem*)addItemIfAllowed:(MenuItemTag)tag title:(NSString*)title { + if ([self allowDisplayOfHeaderItem:tag]) { + NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:title action:@selector(headerMenuItemCallback:) keyEquivalent:@""]; + item.target = [self class]; + item.tag = tag; + item.representedObject = self.title; + [self addItem:item]; + return item; + } + return nil; +} + +/// Prepare @c userInfo dictionary and send @c NSNotification. Callback for every default header menu item. ++ (void)headerMenuItemCallback:(NSMenuItem*)sender { + BOOL openLinks = NO; + NSUInteger limit = 0; + if (sender.tag == TagOpenAllUnread) { + if (sender.isAlternate) + limit = [UserPrefs openFewLinksLimit]; + openLinks = YES; + } else if (sender.tag != TagMarkAllRead && sender.tag != TagMarkAllUnread) { + return; // other menu item clicked. abort and return. + } + BOOL markRead = (sender.tag != TagMarkAllUnread); + BOOL isFeedMenu = NO; + NSString *path = sender.representedObject; + if (path.length > 2) { + isFeedMenu = ([path characterAtIndex:0] == 'F'); + path = [path substringFromIndex:2]; + } else { // main menu + path = nil; + } + NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; + NSArray *list = [StoreCoordinator articlesAtPath:path isFeed:isFeedMenu sorted:openLinks unread:markRead inContext:moc limit:limit]; + + NSNumber *countDiff = [NSNumber numberWithUnsignedInteger:list.count]; + if (markRead) countDiff = [NSNumber numberWithInteger: -1 * countDiff.integerValue]; + + NSMutableArray *urls = [NSMutableArray arrayWithCapacity:list.count]; + for (FeedArticle *fa in list) { + fa.unread = !markRead; + if (openLinks && fa.link.length > 0) + [urls addObject:[NSURL URLWithString:fa.link]]; + } + [StoreCoordinator saveContext:moc andParent:YES]; + [moc reset]; + if (openLinks) + [UserPrefs openURLsWithPreferredBrowser:urls]; + [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged object:countDiff]; +} + +@end + + + +#pragma mark - NSMenuItem Category + +@implementation NSMenuItem (Ext) + +/// Create a copy of an existing menu item and set it's option key modifier. +- (instancetype)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; +} + +/// Remove & append new unread count to title +- (void)setTitleCount:(NSUInteger)count { + if (self.tag == TagTitleCountVisible) { + self.tag = 0; // clear mask + NSUInteger loc = [self.title rangeOfString:@" (" options:NSLiteralSearch | NSBackwardsSearch].location; + if (loc != NSNotFound) + self.title = [self.title substringToIndex:loc]; + } + if (count > 0 && [UserPrefs defaultYES:(self.submenu.isFeedMenu ? @"feedUnreadCount" : @"groupUnreadCount")]) { + self.tag = TagTitleCountVisible; // apply new mask + self.title = [self.title stringByAppendingFormat:@" (%ld)", count]; + } } @end diff --git a/baRSS/Status Bar Menu/NSMenuItem+Ext.h b/baRSS/Status Bar Menu/NSMenuItem+Ext.h deleted file mode 100644 index 5fb5e20..0000000 --- a/baRSS/Status Bar Menu/NSMenuItem+Ext.h +++ /dev/null @@ -1,59 +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 - -static NSString *kSeparatorItemTitle = @"---SEPARATOR---"; - -/// @c NSMenuItem options that are assigned to the @c tag attribute. -typedef NS_OPTIONS(NSInteger, MenuItemTag) { - /// Item visible at the very first menu level @c (StatusBar) - ScopeGlobal = 2, - /// Item visible at each group, e.g., multiple feeds in one group - ScopeGroup = 4, - /// Item visible at the deepest menu level @c (FeedArticle) - ScopeFeed = 8, - /// - TagPreferences = (1 << 4), - TagPauseUpdates = (2 << 4), - TagUpdateFeed = (3 << 4), - TagMarkAllRead = (4 << 4), - TagMarkAllUnread = (5 << 4), - TagOpenAllUnread = (6 << 4), - - TagMaskScope = 0xF, - TagMaskType = 0xFFF0, -}; - -@class FeedGroup, Feed, FeedArticle; - -@interface NSMenuItem (Feed) -+ (NSMenuItem*)itemWithTitle:(NSString*)title action:(SEL)selector target:(id)target tag:(MenuItemTag)tag; -- (NSMenuItem*)alternateWithTitle:(NSString*)title; -- (void)setTarget:(id)target action:(SEL)selector; - -- (void)setFeedGroup:(FeedGroup*)group; -- (void)setFeedArticle:(FeedArticle*)article; -- (NSInteger)setTitleAndUnreadCount:(FeedGroup*)group; - -- (void)iterateSorted:(BOOL)ordered inContext:(NSManagedObjectContext*)moc overDescendentFeeds:(void(^)(Feed *feed, BOOL *cancel))block; -@end diff --git a/baRSS/Status Bar Menu/NSMenuItem+Ext.m b/baRSS/Status Bar Menu/NSMenuItem+Ext.m deleted file mode 100644 index 2695ec4..0000000 --- a/baRSS/Status Bar Menu/NSMenuItem+Ext.m +++ /dev/null @@ -1,225 +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+Ext.h" -#import "NSMenu+Ext.h" -#import "StoreCoordinator.h" -#import "DrawImage.h" -#import "UserPrefs.h" -#import "Feed+Ext.h" -#import "FeedGroup+Ext.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) - -#pragma mark - General helper methods - - -/** - Helper method to generate a new @c NSMenuItem. - */ -+ (NSMenuItem*)itemWithTitle:(NSString*)title action:(SEL)selector target:(id)target tag:(MenuItemTag)tag { - NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:title action:selector keyEquivalent:@""]; - item.target = target; - item.tag = tag; - [item applyUserSettingsDisplay]; - return item; -} - -/** - 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; -} - -/** - Convenient method to set @c target and @c action simultaneously. - */ -- (void)setTarget:(id)target action:(SEL)selector { - self.target = target; - self.action = selector; -} - - -#pragma mark - Set properties based on Core Data object - - - -/** - Set title based on preferences either with or without unread count in parenthesis. - - @return Number of unread items. (@b warning: May return @c 0 if visibility is disabled in @c UserPrefs) - */ -- (NSInteger)setTitleAndUnreadCount:(FeedGroup*)fg { - NSInteger uCount = 0; - if (fg.type == FEED && [UserPrefs defaultYES:@"feedUnreadCount"]) { - uCount = fg.feed.unreadCount; - } else if (fg.type == GROUP && [UserPrefs defaultYES:@"groupUnreadCount"]) { - uCount = [self.submenu coreDataUnreadCount]; - } - NSString *name = (fg.name ? fg.name : NSLocalizedString(@"(error)", nil)); - self.title = (uCount == 0 ? name : [NSString stringWithFormat:@"%@ (%ld)", name, uCount]); - return uCount; -} - -/** - Fully configures a Separator item OR group item OR feed item. (but not @c FeedArticle item) - */ -- (void)setFeedGroup:(FeedGroup*)fg { - self.representedObject = fg.objectID; - if (fg.type == SEPARATOR) { - self.title = kSeparatorItemTitle; - } else { - self.submenu = [self.menu submenuWithIndex:fg.sortIndex isFeed:(fg.type == FEED)]; - [self setTitleAndUnreadCount:fg]; // after submenu is set - if (fg.type == FEED) { - self.tag = ScopeFeed; - self.toolTip = fg.feed.subtitle; - self.enabled = (fg.feed.articles.count > 0); - self.image = [fg.feed iconImage16]; - } else { - self.tag = ScopeGroup; - self.enabled = (fg.children.count > 0); - self.image = [fg groupIconImage16]; - } - } -} - -/** - Populate @c NSMenuItem based on the attributes of a @c FeedArticle. - */ -- (void)setFeedArticle:(FeedArticle*)fa { - self.title = fa.title; - // TODO: It should be enough to get user prefs once per menu build - if ([UserPrefs defaultNO:@"feedShortNames"]) { - NSUInteger limit = [UserPrefs shortArticleNamesLimit]; - if (self.title.length > limit) - self.title = [NSString stringWithFormat:@"%@…", [self.title substringToIndex:limit-1]]; - } - self.tag = ScopeFeed; - self.enabled = (fa.link.length > 0); - self.state = (fa.unread && [UserPrefs defaultYES:@"feedTickMark"] ? NSControlStateValueOn : NSControlStateValueOff); - self.representedObject = fa.objectID; - //mi.toolTip = item.abstract; - // TODO: Do regex during save, not during display. Its here for testing purposes ... - if (fa.abstract.length > 0) { - NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"<[^>]*>" options:kNilOptions error:nil]; - self.toolTip = [regex stringByReplacingMatchesInString:fa.abstract options:kNilOptions range:NSMakeRange(0, fa.abstract.length) withTemplate:@""]; - } -} - -#pragma mark - Helper - - -/** - @return @c FeedGroup object if @c representedObject contains a valid @c NSManagedObjectID. - */ -- (FeedGroup*)requestGroup:(NSManagedObjectContext*)moc { - if (!self.representedObject || ![self.representedObject isKindOfClass:[NSManagedObjectID class]]) - return nil; - FeedGroup *fg = [moc objectWithID:self.representedObject]; - if (![fg isKindOfClass:[FeedGroup class]]) - return nil; - return fg; -} - -/** - Perform @c block on every @c FeedGroup in the items menu or any of its submenues. - - @param ordered Whether order matters or not. If all items are processed anyway, pass @c NO for a speedup. - @param block Set cancel to @c YES to stop enumeration early. - */ -- (void)iterateSorted:(BOOL)ordered inContext:(NSManagedObjectContext*)moc overDescendentFeeds:(void(^)(Feed*,BOOL*))block { - if (self.parentItem) { - [[self.parentItem requestGroup:moc] iterateSorted:ordered overDescendantFeeds:block]; - } else { - for (NSMenuItem *item in self.menu.itemArray) { - FeedGroup *fg = [item requestGroup:moc]; - if (fg != nil) { // All groups and feeds; Ignore default header - if (![fg 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/StoreCoordinator.m b/baRSS/StoreCoordinator.m deleted file mode 100644 index 479101f..0000000 --- a/baRSS/StoreCoordinator.m +++ /dev/null @@ -1,247 +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 "StoreCoordinator.h" -#import "AppHook.h" -#import "Feed+Ext.h" - -@implementation StoreCoordinator - -#pragma mark - Managing contexts - -/// @return The application main persistent context. -+ (NSManagedObjectContext*)getMainContext { - return [(AppHook*)NSApp persistentContainer].viewContext; -} - -/// New child context with @c NSMainQueueConcurrencyType and without undo manager. -+ (NSManagedObjectContext*)createChildContext { - NSManagedObjectContext *context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType]; - [context setParentContext:[self getMainContext]]; - context.undoManager = nil; - //context.automaticallyMergesChangesFromParent = YES; - return context; -} - -/** - Commit changes and perform save operation on @c context. - - @param flag If @c YES save any parent context as well (recursive). - */ -+ (void)saveContext:(NSManagedObjectContext*)context andParent:(BOOL)flag { - // Performs the save action for the application, which is to send the save: message to the application's managed object context. Any encountered errors are presented to the user. - if (![context commitEditing]) { - NSLog(@"%@:%@ unable to commit editing before saving", [self class], NSStringFromSelector(_cmd)); - } - NSError *error = nil; - if (context.hasChanges && ![context save:&error]) { - // Customize this code block to include application-specific recovery steps. - [[NSApplication sharedApplication] presentError:error]; - } - if (flag && context.parentContext) { - [self saveContext:context.parentContext andParent:flag]; - } -} - - -#pragma mark - Helper - -/// Perform fetch and return result. If an error occurs, print it to the console. -+ (NSArray*)fetchAllRows:(NSFetchRequest*)req inContext:(NSManagedObjectContext*)moc { - NSError *err; - NSArray *fetchResults = [moc executeFetchRequest:req error:&err]; - if (err) NSLog(@"ERROR: Fetch request failed: %@", err); - //NSLog(@"%@ ==> %@", req, fetchResults); // debugging - return fetchResults; -} - -/// Perform aggregated fetch where result is a single row. Use convenient methods @c fetchDate: or @c fetchInteger:. -+ (id)fetchSingleRow:(NSManagedObjectContext*)moc request:(NSFetchRequest*)req expression:(NSExpression*)exp resultType:(NSAttributeType)type { - NSExpressionDescription *expDesc = [[NSExpressionDescription alloc] init]; - [expDesc setName:@"singleRowAttribute"]; - [expDesc setExpression:exp]; - [expDesc setExpressionResultType:type]; - [req setResultType:NSDictionaryResultType]; - [req setPropertiesToFetch:@[expDesc]]; - return [self fetchAllRows:req inContext:moc].firstObject[@"singleRowAttribute"]; -} - -/// Convenient method on @c fetchSingleRow: with @c NSDate return type. May be @c nil. -+ (NSDate*)fetchDate:(NSManagedObjectContext*)moc request:(NSFetchRequest*)req expression:(NSExpression*)exp { - return [self fetchSingleRow:moc request:req expression:exp resultType:NSDateAttributeType]; // can be nil -} - -/// Convenient method on @c fetchSingleRow: with @c NSInteger return type. -+ (NSInteger)fetchInteger:(NSManagedObjectContext*)moc request:(NSFetchRequest*)req expression:(NSExpression*)exp { - return [[self fetchSingleRow:moc request:req expression:exp resultType:NSInteger32AttributeType] integerValue]; -} - - -#pragma mark - Feed Update - -/** - List of @c Feed items that need to be updated. Scheduled time is now (or in past). - - @param forceAll If @c YES get a list of all @c Feed regardless of schedules time. - */ -+ (NSArray*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc { - NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name]; - if (!forceAll) { - // when fetching also get those feeds that would need update soon (now + 10s) - fr.predicate = [NSPredicate predicateWithFormat:@"meta.scheduled <= %@", [NSDate dateWithTimeIntervalSinceNow:+10]]; - } - return [self fetchAllRows:fr inContext:moc]; -} - -/// @return @c NSDate of next (earliest) feed update. May be @c nil. -+ (NSDate*)nextScheduledUpdate { - // Always get context first, or 'FeedMeta.entity.name' may not be available on app start - NSManagedObjectContext *moc = [self getMainContext]; - NSExpression *exp = [NSExpression expressionForFunction:@"min:" arguments:@[[NSExpression expressionForKeyPath:@"scheduled"]]]; - NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedMeta.entity.name]; - return [self fetchDate:moc request:fr expression:exp]; -} - - -#pragma mark - Main Menu Display - -/** - Perform core data fetch request with sum over all unread feeds matching @c str. - - @param str A dot separated string of integer index parts. - */ -+ (NSInteger)unreadCountForIndexPathString:(NSString*)str { - // Always get context first, or 'Feed.entity.name' may not be available on app start - NSManagedObjectContext *moc = [self getMainContext]; - NSExpression *exp = [NSExpression expressionForFunction:@"sum:" arguments:@[[NSExpression expressionForKeyPath:@"unreadCount"]]]; - NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name]; - if (str && str.length > 0) - fr.predicate = [NSPredicate predicateWithFormat:@"indexPath BEGINSWITH %@", [str stringByAppendingString:@"."]]; - return [self fetchInteger:moc request:fr expression:exp]; -} - -/** - Get sorted list of @c ObjectIDs for either @c FeedGroup or @c FeedArticle. - - @param parent Either @c ObjectID or actual object. Or @c nil for root folder. - @param flag If @c YES request list of @c FeedArticle instead of @c FeedGroup - */ -+ (NSArray*)sortedObjectIDsForParent:(id)parent isFeed:(BOOL)flag inContext:(NSManagedObjectContext*)moc { - NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: (flag ? FeedArticle.entity : FeedGroup.entity).name]; - fr.predicate = [NSPredicate predicateWithFormat:(flag ? @"feed.group = %@" : @"parent = %@"), parent]; - fr.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:!flag]]; - [fr setResultType:NSManagedObjectIDResultType]; // only get ids - return [self fetchAllRows:fr inContext:moc]; -} - - -#pragma mark - OPML Import & Export - -/// @return Count of objects at root level. Also the @c sortIndex for the next item. -+ (NSInteger)numberRootItemsInContext:(NSManagedObjectContext*)moc { - NSExpression *exp = [NSExpression expressionForFunction:@"count:" arguments:@[[NSExpression expressionForEvaluatedObject]]]; - NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedGroup.entity.name]; - fr.predicate = [NSPredicate predicateWithFormat:@"parent = NULL"]; - return [self fetchInteger:moc request:fr expression:exp]; -} - -/// @return Sorted list of root element objects. -+ (NSArray*)sortedListOfRootObjectsInContext:(NSManagedObjectContext*)moc { - NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedGroup.entity.name]; - fr.predicate = [NSPredicate predicateWithFormat:@"parent = NULL"]; - fr.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]]; - return [self fetchAllRows:fr inContext:moc]; -} - - -#pragma mark - Restore Sound State - -/** - Perform batch delete on entities of type @c entity where @c column @c IS @c NULL. If @c column is @c nil, delete all rows. - */ -+ (NSUInteger)batchDelete:(NSEntityDescription*)entity nullAttribute:(NSString*)column inContext:(NSManagedObjectContext*)moc { - NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: entity.name]; - if (column && column.length > 0) { - // double nested string, otherwise column is not interpreted as such. - // using @count here to also find items where foreign key is set but referencing a non-existing object. - fr.predicate = [NSPredicate predicateWithFormat:[NSString stringWithFormat:@"count(%@) == 0", column]]; - } - NSBatchDeleteRequest *bdr = [[NSBatchDeleteRequest alloc] initWithFetchRequest:fr]; - bdr.resultType = NSBatchDeleteResultTypeCount; - NSError *err; - NSBatchDeleteResult *lol = [moc executeRequest:bdr error:&err]; - if (err) NSLog(@"%@", err); - return [lol.result unsignedIntegerValue]; -} - -/** - Delete all @c FeedGroup items. - */ -+ (NSUInteger)deleteAllGroups { - NSManagedObjectContext *moc = [self getMainContext]; - NSUInteger deleted = [self batchDelete:FeedGroup.entity nullAttribute:nil inContext:moc]; - [self saveContext:moc andParent:YES]; - return deleted; -} - -/** - Delete all @c Feed items where @c group @c = @c NULL and all @c FeedMeta, @c FeedIcon, @c FeedArticle where @c feed @c = @c NULL. - */ -+ (NSUInteger)deleteUnreferenced { - NSUInteger deleted = 0; - NSManagedObjectContext *moc = [self getMainContext]; - deleted += [self batchDelete:Feed.entity nullAttribute:@"group" inContext:moc]; - deleted += [self batchDelete:FeedMeta.entity nullAttribute:@"feed" inContext:moc]; - deleted += [self batchDelete:FeedIcon.entity nullAttribute:@"feed" inContext:moc]; - deleted += [self batchDelete:FeedArticle.entity nullAttribute:@"feed" inContext:moc]; - [self saveContext:moc andParent:YES]; - return deleted; -} - -/** - Iterate over all @c Feed and re-calculate @c unreadCount and @c indexPath. - */ -+ (void)restoreFeedCountsAndIndexPaths { - NSManagedObjectContext *moc = [self getMainContext]; - NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name]; - for (Feed *f in [self fetchAllRows:fr inContext:moc]) { - [f calculateAndSetUnreadCount]; - [f calculateAndSetIndexPathString]; - } - [self saveContext:moc andParent:YES]; -} - -/// @return All @c Feed items where @c articles.count @c == @c 0 -+ (NSArray*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc { - NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name]; - fr.predicate = [NSPredicate predicateWithFormat:@"articles.@count == 0"]; - return [self fetchAllRows:fr inContext:moc]; -} - -/// @return All @c Feed items where @c icon is @c nil. -+ (NSArray*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc { - NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name]; - fr.predicate = [NSPredicate predicateWithFormat:@"icon = NULL"]; - return [self fetchAllRows:fr inContext:moc]; -} - -@end