Refactoring Status Menu

This commit is contained in:
relikd
2019-02-10 19:39:51 +01:00
parent cd0a1a3fd7
commit f2cca57fbb
39 changed files with 1491 additions and 1254 deletions

View File

@@ -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;
};

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View 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

View 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

View File

@@ -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;

View File

@@ -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 -

View 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

View 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

View File

@@ -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

View 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

View File

@@ -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"/>

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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];
}

View File

@@ -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];

View File

@@ -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];

View File

@@ -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"/>

View File

@@ -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 {

View File

@@ -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'

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View 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

View 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

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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