Refactoring Status Menu
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
54209E922117325100F3B5EF /* DrawImage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DrawImage.h; sourceTree = "<group>"; };
|
||||
54209E932117325100F3B5EF /* DrawImage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DrawImage.m; sourceTree = "<group>"; };
|
||||
543695D6214F1F2700DA979D /* NSMenuItem+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSMenuItem+Ext.h"; sourceTree = "<group>"; };
|
||||
543695D7214F1F2700DA979D /* NSMenuItem+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSMenuItem+Ext.m"; sourceTree = "<group>"; };
|
||||
544936F921F1E66100DEE9AA /* Statistics.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Statistics.h; sourceTree = "<group>"; };
|
||||
544936FA21F1E66100DEE9AA /* Statistics.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Statistics.m; sourceTree = "<group>"; };
|
||||
544B01182114B41200386E5C /* ModalSheet.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ModalSheet.h; sourceTree = "<group>"; };
|
||||
@@ -107,6 +108,10 @@
|
||||
5477D34D21233C62002BA27F /* FeedGroup+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "FeedGroup+Ext.m"; sourceTree = "<group>"; };
|
||||
5496B50F214D6275003ED4ED /* UserPrefs.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UserPrefs.h; sourceTree = "<group>"; };
|
||||
5496B510214D6275003ED4ED /* UserPrefs.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UserPrefs.m; sourceTree = "<group>"; };
|
||||
54A07A7D220E04CF00082C51 /* NSFetchRequest+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSFetchRequest+Ext.h"; sourceTree = "<group>"; };
|
||||
54A07A7E220E04CF00082C51 /* NSFetchRequest+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSFetchRequest+Ext.m"; sourceTree = "<group>"; };
|
||||
54A07A80220E723D00082C51 /* MapUnreadTotal.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MapUnreadTotal.h; sourceTree = "<group>"; };
|
||||
54A07A81220E723D00082C51 /* MapUnreadTotal.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MapUnreadTotal.m; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
54ACC28521061B3C0020715F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
@@ -116,6 +121,10 @@
|
||||
54ACC29421061E270020715F /* FeedDownload.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FeedDownload.m; sourceTree = "<group>"; };
|
||||
54ACC29621061FBA0020715F /* Preferences.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Preferences.h; sourceTree = "<group>"; };
|
||||
54ACC29721061FBA0020715F /* Preferences.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = Preferences.m; sourceTree = "<group>"; };
|
||||
54B749D82204A85C0022CC6D /* BarStatusItem.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BarStatusItem.h; sourceTree = "<group>"; };
|
||||
54B749D92204A85C0022CC6D /* BarStatusItem.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BarStatusItem.m; sourceTree = "<group>"; };
|
||||
54B749DE220635BE0022CC6D /* FeedArticle+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FeedArticle+Ext.h"; sourceTree = "<group>"; };
|
||||
54B749DF220635CD0022CC6D /* FeedArticle+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "FeedArticle+Ext.m"; sourceTree = "<group>"; };
|
||||
54BB048721FD2AB500C303A5 /* NSDate+Ext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSDate+Ext.h"; sourceTree = "<group>"; };
|
||||
54BB048821FD2AB500C303A5 /* NSDate+Ext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSDate+Ext.m"; sourceTree = "<group>"; };
|
||||
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 = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
@@ -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 = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -23,9 +23,11 @@
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import <CoreData/CoreData.h>
|
||||
|
||||
@class BarMenu;
|
||||
@class BarStatusItem;
|
||||
|
||||
@interface AppHook : NSApplication <NSApplicationDelegate>
|
||||
@property (readonly, strong) BarMenu *barMenu;
|
||||
@property (readonly, strong) BarStatusItem *statusItem;
|
||||
@property (readonly, strong) NSPersistentContainer *persistentContainer;
|
||||
|
||||
- (void)openPreferences;
|
||||
@end
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
// SOFTWARE.
|
||||
|
||||
#import "Feed+CoreDataClass.h"
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
@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<FeedArticle*>*)sortedArticles;
|
||||
- (int)markAllItemsRead;
|
||||
- (int)markAllItemsUnread;
|
||||
// Icon
|
||||
- (NSImage*)iconImage16;
|
||||
- (BOOL)setIconImage:(NSImage*)img;
|
||||
@@ -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 <RSXML/RSXML.h>
|
||||
@@ -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<NSString*> *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<NSString*>*)urls {
|
||||
int32_t newOnes = 0;
|
||||
- (NSInteger)addMissingArticles:(RSParsedFeed*)obj updateLinks:(NSMutableSet<NSString*>*)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<NSString*>*)urls {
|
||||
- (NSUInteger)deleteArticlesWithLink:(NSMutableSet<NSString*>*)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;
|
||||
31
baRSS/Core Data/FeedArticle+Ext.h
Normal file
31
baRSS/Core Data/FeedArticle+Ext.h
Normal file
@@ -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 <Cocoa/Cocoa.h>
|
||||
|
||||
@class RSParsedArticle;
|
||||
|
||||
@interface FeedArticle (Ext)
|
||||
+ (instancetype)newArticle:(RSParsedArticle*)entry inContext:(NSManagedObjectContext*)moc;
|
||||
- (NSMenuItem*)newMenuItem;
|
||||
@end
|
||||
94
baRSS/Core Data/FeedArticle+Ext.m
Normal file
94
baRSS/Core Data/FeedArticle+Ext.m
Normal file
@@ -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 <RSXML/RSParsedArticle.h>
|
||||
|
||||
@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
|
||||
@@ -21,6 +21,7 @@
|
||||
// SOFTWARE.
|
||||
|
||||
#import "FeedGroup+CoreDataClass.h"
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
/// 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<FeedGroup*>*)sortedChildren;
|
||||
@@ -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 -
|
||||
|
||||
38
baRSS/Core Data/NSFetchRequest+Ext.h
Normal file
38
baRSS/Core Data/NSFetchRequest+Ext.h
Normal file
@@ -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 <CoreData/CoreData.h>
|
||||
|
||||
@interface NSFetchRequest<ResultType> (Ext)
|
||||
// Perform core data request and fetch data
|
||||
- (NSArray<ResultType>*)fetchAllRows:(NSManagedObjectContext*)moc;
|
||||
- (NSArray<NSManagedObjectID*>*)fetchIDs:(NSManagedObjectContext*)moc;
|
||||
- (NSUInteger)fetchCount:(NSManagedObjectContext*)moc;
|
||||
- (id)fetchFirst:(NSManagedObjectContext*)moc; // limit 1
|
||||
|
||||
// Selecting, filtering, sorting results
|
||||
- (instancetype)select:(NSArray<NSString*>*)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
|
||||
133
baRSS/Core Data/NSFetchRequest+Ext.m
Normal file
133
baRSS/Core Data/NSFetchRequest+Ext.m
Normal file
@@ -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<NSManagedObjectID*>*)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<NSString*>*)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
|
||||
@@ -27,19 +27,29 @@
|
||||
// Managing contexts
|
||||
+ (NSManagedObjectContext*)createChildContext;
|
||||
+ (void)saveContext:(NSManagedObjectContext*)context andParent:(BOOL)flag;
|
||||
|
||||
// Feed update
|
||||
+ (NSArray<Feed*>*)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<FeedGroup*>*)sortedListOfRootObjectsInContext:(NSManagedObjectContext*)moc;
|
||||
// Restore sound state
|
||||
+ (NSUInteger)deleteAllGroups;
|
||||
+ (NSUInteger)deleteUnreferenced;
|
||||
+ (void)restoreFeedCountsAndIndexPaths;
|
||||
+ (NSArray<Feed*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc;
|
||||
|
||||
// Count elements
|
||||
+ (NSUInteger)countTotalUnread;
|
||||
+ (NSUInteger)countRootItemsInContext:(NSManagedObjectContext*)moc;
|
||||
+ (NSArray<NSDictionary*>*)countAggregatedUnread;
|
||||
|
||||
// Get List Of Elements
|
||||
+ (NSArray<FeedGroup*>*)sortedFeedGroupsWithParent:(id)parent inContext:(NSManagedObjectContext*)moc;
|
||||
+ (NSArray<FeedArticle*>*)sortedArticlesWithParent:(id)parent inContext:(NSManagedObjectContext*)moc;
|
||||
+ (NSArray<Feed*>*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc;
|
||||
+ (NSArray<Feed*>*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc;
|
||||
+ (Feed*)feedWithIndexPath:(nonnull NSString*)path inContext:(NSManagedObjectContext*)moc;
|
||||
+ (NSString*)urlForFeedWithIndexPath:(nonnull NSString*)path;
|
||||
|
||||
// Unread articles list & mark articled read
|
||||
+ (NSArray<FeedArticle*>*)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
|
||||
253
baRSS/Core Data/StoreCoordinator.m
Normal file
253
baRSS/Core Data/StoreCoordinator.m
Normal file
@@ -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<Feed*>*)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<NSDictionary*>*)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<FeedGroup*>*)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<FeedArticle*>*)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<Feed*>*)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<Feed*>*)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<NSManagedObjectID*>*)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<FeedArticle*>*)articlesAtPath:(nullable NSString*)path isFeed:(BOOL)feedFlag sorted:(BOOL)sortFlag unread:(BOOL)readFlag inContext:(NSManagedObjectContext*)moc limit:(NSUInteger)limit {
|
||||
NSFetchRequest<FeedArticle*> *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
|
||||
@@ -5,7 +5,6 @@
|
||||
<attribute name="link" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<attribute name="subtitle" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<attribute name="title" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<attribute name="unreadCount" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
|
||||
<relationship name="articles" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="FeedArticle" inverseName="feed" inverseEntity="FeedArticle" syncable="YES"/>
|
||||
<relationship name="group" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="FeedGroup" inverseName="feed" inverseEntity="FeedGroup" syncable="YES"/>
|
||||
<relationship name="icon" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="FeedIcon" inverseName="feed" inverseEntity="FeedIcon" syncable="YES"/>
|
||||
@@ -45,7 +44,7 @@
|
||||
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="meta" inverseEntity="Feed" syncable="YES"/>
|
||||
</entity>
|
||||
<elements>
|
||||
<element name="Feed" positionX="-278.84765625" positionY="-112.953125" width="128" height="180"/>
|
||||
<element name="Feed" positionX="-278.84765625" positionY="-112.953125" width="128" height="165"/>
|
||||
<element name="FeedArticle" positionX="-96.77734375" positionY="-113.83984375" width="128" height="195"/>
|
||||
<element name="FeedGroup" positionX="-460.37890625" positionY="-111.62890625" width="130.52734375" height="135"/>
|
||||
<element name="FeedIcon" positionX="-202.79296875" positionY="137.71875" width="128" height="75"/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 <Foundation/Foundation.h>
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
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
|
||||
|
||||
@@ -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 <QuartzCore/QuartzCore.h>
|
||||
|
||||
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
|
||||
|
||||
@@ -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<RefreshIntervalButtonDelegate>)callback {
|
||||
+ (NSView*)viewWithLabel:(NSString*)title andInterval:(NSNumber*)value callback:(nullable id<RefreshIntervalButtonDelegate>)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;
|
||||
}
|
||||
|
||||
@@ -29,8 +29,6 @@
|
||||
#import "Statistics.h"
|
||||
#import "NSDate+Ext.h"
|
||||
|
||||
#import <QuartzCore/QuartzCore.h>
|
||||
|
||||
|
||||
#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];
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
[sp beginSheetModalForWindow:window completionHandler:^(NSModalResponse result) {
|
||||
if (result == NSModalResponseOK) {
|
||||
BOOL flattened = ([self radioGroupSelection:radioView] == 1);
|
||||
NSArray<FeedGroup*> *list = [StoreCoordinator sortedListOfRootObjectsInContext:moc];
|
||||
NSArray<FeedGroup*> *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<Feed*> *list = [NSMutableArray array];
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -178,6 +178,7 @@
|
||||
<string key="keyEquivalent" base64-UTF8="YES">
|
||||
CA
|
||||
</string>
|
||||
<modifierMask key="keyEquivalentModifierMask" command="YES"/>
|
||||
</buttonCell>
|
||||
<connections>
|
||||
<action selector="remove:" target="-2" id="JeR-iq-Gjb"/>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
+ (NSString*)getHttpApplication;
|
||||
+ (void)setHttpApplication:(NSString*)bundleID;
|
||||
+ (void)openURLsWithPreferredBrowser:(NSArray<NSURL*>*)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'
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
// SOFTWARE.
|
||||
|
||||
#import "UserPrefs.h"
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
@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<NSURL*>*)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.
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
@class BarStatusItem;
|
||||
|
||||
@interface BarMenu : NSObject <NSMenuDelegate>
|
||||
- (void)updateBarIcon;
|
||||
- (instancetype)init NS_UNAVAILABLE;
|
||||
- (instancetype)initWithStatusItem:(BarStatusItem*)statusItem NS_DESIGNATED_INITIALIZER;
|
||||
@end
|
||||
|
||||
@@ -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<NSManagedObjectID*> *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<NSURL*> *urls = [NSMutableArray<NSURL*> 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<FeedGroup*> *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];
|
||||
}
|
||||
}
|
||||
*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];
|
||||
/// Get rid of everything that is not needed.
|
||||
- (void)menuDidClose:(NSMenu*)menu {
|
||||
[menu cleanup];
|
||||
}
|
||||
|
||||
/**
|
||||
Called when user clicks on a single feed item or the feed group.
|
||||
/// Generate items for @c FeedGroup menu.
|
||||
- (void)setFeedGroups:(NSArray<FeedGroup*>*)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];
|
||||
}
|
||||
}
|
||||
|
||||
@param sender A menu item containing either a @c FeedArticle or a @c FeedGroup objectID.
|
||||
/// Generate items for @c FeedArticles menu.
|
||||
- (void)setArticles:(NSArray<FeedArticle*>*)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)openFeedURL:(NSMenuItem*)sender {
|
||||
NSManagedObjectID *oid = sender.representedObject;
|
||||
if (!oid)
|
||||
- (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;
|
||||
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];
|
||||
}
|
||||
}
|
||||
NSMenuItem *item = [self.statusItem.mainMenu deepestItemWithPath:feed.indexPath];
|
||||
if (!item) {
|
||||
[moc reset];
|
||||
return;
|
||||
}
|
||||
block(feed, item);
|
||||
[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.
|
||||
/// 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];
|
||||
}
|
||||
}
|
||||
// 3. set unread count & enabled header for all parents
|
||||
NSArray<UnreadTotal*> *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;
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
@param urls A list of @c NSURL objects that will be opened immediatelly in bulk.
|
||||
*/
|
||||
- (void)openURLsWithPreferredBrowser:(NSArray<NSURL*>*)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
|
||||
|
||||
33
baRSS/Status Bar Menu/BarStatusItem.h
Normal file
33
baRSS/Status Bar Menu/BarStatusItem.h
Normal file
@@ -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 <Cocoa/Cocoa.h>
|
||||
|
||||
@interface BarStatusItem : NSObject
|
||||
@property (weak, readonly) NSMenu *mainMenu;
|
||||
|
||||
- (void)setUnreadCountAbsolute:(NSUInteger)count;
|
||||
- (void)setUnreadCountRelative:(NSInteger)count;
|
||||
- (void)asyncReloadUnreadCount;
|
||||
- (void)updateBarIcon;
|
||||
@end
|
||||
|
||||
182
baRSS/Status Bar Menu/BarStatusItem.m
Normal file
182
baRSS/Status Bar Menu/BarStatusItem.m
Normal file
@@ -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
|
||||
41
baRSS/Status Bar Menu/MapUnreadTotal.h
Normal file
41
baRSS/Status Bar Menu/MapUnreadTotal.h
Normal file
@@ -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 <Foundation/Foundation.h>
|
||||
|
||||
@interface UnreadTotal : NSObject
|
||||
@property (nonatomic, assign) NSUInteger unread;
|
||||
@property (nonatomic, assign) NSUInteger total;
|
||||
@end
|
||||
|
||||
|
||||
@interface MapUnreadTotal : NSObject
|
||||
- (instancetype)init NS_UNAVAILABLE;
|
||||
- (instancetype)initWithCoreData:(NSArray<NSDictionary*>*)data NS_DESIGNATED_INITIALIZER;
|
||||
|
||||
- (NSArray<UnreadTotal*>*)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
|
||||
94
baRSS/Status Bar Menu/MapUnreadTotal.m
Normal file
94
baRSS/Status Bar Menu/MapUnreadTotal.m
Normal file
@@ -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<NSString*, UnreadTotal*> *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<NSDictionary*>*)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<UnreadTotal*>*)itemsForPath:(NSString*)path create:(BOOL)flag {
|
||||
NSMutableArray<UnreadTotal*> *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: %lu, total: %lu>", _unread, _total]; }
|
||||
@end
|
||||
@@ -21,20 +21,26 @@
|
||||
// SOFTWARE.
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#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<NSMenuDelegate>)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
|
||||
|
||||
@@ -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<NSMenuDelegate>)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'); }
|
||||
|
||||
|
||||
#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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/// @return Either @c ScopeGlobal, @c ScopeGroup or @c ScopeFeed.
|
||||
- (MenuItemTag)scope {
|
||||
if ([self isFeedMenu]) return ScopeFeed;
|
||||
if ([self isMainMenu]) return ScopeGlobal;
|
||||
return ScopeGroup;
|
||||
/// 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]]]];
|
||||
}
|
||||
|
||||
/// @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;
|
||||
[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];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// 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];
|
||||
}
|
||||
|
||||
|
||||
#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<FeedArticle*> *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<NSURL*> *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
|
||||
|
||||
@@ -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 <Cocoa/Cocoa.h>
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -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<Feed*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc {
|
||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
|
||||
if (!forceAll) {
|
||||
// when fetching also get those feeds that would need update soon (now + 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<FeedGroup*>*)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<Feed*>*)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<Feed*>*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc {
|
||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
|
||||
fr.predicate = [NSPredicate predicateWithFormat:@"icon = NULL"];
|
||||
return [self fetchAllRows:fr inContext:moc];
|
||||
}
|
||||
|
||||
@end
|
||||
Reference in New Issue
Block a user