diff --git a/baRSS.xcodeproj/project.pbxproj b/baRSS.xcodeproj/project.pbxproj index 34086f6..0ed7938 100644 --- a/baRSS.xcodeproj/project.pbxproj +++ b/baRSS.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 1968E0AE14B8E8A90E194980 /* PyHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 1968EF7567E06D2A5BB3481A /* PyHandler.m */; }; + 541A90F221257D77002680A6 /* MenuItemInfo.m in Sources */ = {isa = PBXBuildFile; fileRef = 541A90F121257D77002680A6 /* MenuItemInfo.m */; }; 54209E942117325100F3B5EF /* DrawImage.m in Sources */ = {isa = PBXBuildFile; fileRef = 54209E932117325100F3B5EF /* DrawImage.m */; }; 544B011A2114B41200386E5C /* ModalSheet.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B01192114B41200386E5C /* ModalSheet.m */; }; 544B011D2114EE9100386E5C /* AppHook.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B011C2114EE9100386E5C /* AppHook.m */; }; @@ -34,6 +35,8 @@ /* Begin PBXFileReference section */ 1968E7919BAA36F042FCB717 /* PyHandler.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PyHandler.h; sourceTree = ""; }; 1968EF7567E06D2A5BB3481A /* PyHandler.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PyHandler.m; sourceTree = ""; }; + 541A90F021257D77002680A6 /* MenuItemInfo.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MenuItemInfo.h; sourceTree = ""; }; + 541A90F121257D77002680A6 /* MenuItemInfo.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MenuItemInfo.m; sourceTree = ""; }; 54209E922117325100F3B5EF /* DrawImage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DrawImage.h; sourceTree = ""; }; 54209E932117325100F3B5EF /* DrawImage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DrawImage.m; sourceTree = ""; }; 544B01182114B41200386E5C /* ModalSheet.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ModalSheet.h; sourceTree = ""; }; @@ -82,6 +85,17 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 541A90EF21257D4F002680A6 /* Status Bar Menu */ = { + isa = PBXGroup; + children = ( + 541A90F021257D77002680A6 /* MenuItemInfo.h */, + 541A90F121257D77002680A6 /* MenuItemInfo.m */, + 54FE73D1212316CD003EAC65 /* BarMenu.h */, + 54FE73D2212316CD003EAC65 /* BarMenu.m */, + ); + path = "Status Bar Menu"; + sourceTree = ""; + }; 544FBD4321064AEB008A260C /* Frameworks */ = { isa = PBXGroup; children = ( @@ -150,8 +164,7 @@ 549369F421091E6D001AF895 /* python */, 544B011B2114EE9100386E5C /* AppHook.h */, 544B011C2114EE9100386E5C /* AppHook.m */, - 54FE73D1212316CD003EAC65 /* BarMenu.h */, - 54FE73D2212316CD003EAC65 /* BarMenu.m */, + 541A90EF21257D4F002680A6 /* Status Bar Menu */, 54FE73CE21220DEC003EAC65 /* StoreCoordinator.h */, 54FE73CF21220DEC003EAC65 /* StoreCoordinator.m */, 54ACC29321061E270020715F /* FeedDownload.h */, @@ -277,6 +290,7 @@ 1968E0AE14B8E8A90E194980 /* PyHandler.m in Sources */, 54209E942117325100F3B5EF /* DrawImage.m in Sources */, 54FE73D021220DEC003EAC65 /* StoreCoordinator.m in Sources */, + 541A90F221257D77002680A6 /* MenuItemInfo.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/baRSS/BarMenu.m b/baRSS/BarMenu.m deleted file mode 100644 index 915f8d3..0000000 --- a/baRSS/BarMenu.m +++ /dev/null @@ -1,196 +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 "BarMenu.h" -#import "StoreCoordinator.h" -#import "DrawImage.h" -#import "Preferences.h" - -@interface BarMenu() -@property (strong) NSStatusItem *barItem; -@property (strong) Preferences *prefWindow; -@property (weak) NSMenu *mm; -@end - - -@implementation BarMenu - -- (instancetype)init { - self = [super init]; - self.barItem = [self statusItem]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(preferencesClosed) name:@"baRSSPreferencesClosed" object:nil]; -// [self donothing]; - return self; -} - -- (void)donothing { - dispatch_async(dispatch_get_main_queue(), ^{ - [self.mm itemAtIndex:4].title = [NSString stringWithFormat:@"%@", [NSDate date]]; - }); - sleep(1); - [self performSelectorInBackground:@selector(donothing) withObject:nil]; -} - --(void)dealloc { - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -- (NSStatusItem*)statusItem { - NSStatusItem *item = [NSStatusBar.systemStatusBar statusItemWithLength:NSVariableStatusItemLength]; - item.title = @"me"; - item.menu = self.mainMenu; - item.highlightMode = YES; - item.image = [[RSSIcon templateIcon:16 tint:nil] image]; - item.image.template = YES; - return item; -} - -- (void)openPreferences { - if (!self.prefWindow) - self.prefWindow = [[Preferences alloc] initWithWindowNibName:@"Preferences"]; - [NSApp activateIgnoringOtherApps:YES]; - [self.prefWindow showWindow:nil]; -} - -- (void)preferencesClosed { - self.prefWindow = nil; -} - -#pragma mark - Main Menu Item Actions - -- (void)pauseUpdates { - NSLog(@"1pause"); -} - -- (void)updateAllFeeds { - NSLog(@"1update all"); -} - -- (void)openAllUnread { - NSLog(@"1all unread"); -} - -- (void)openFeedURL:(NSMenuItem*)sender { - id obj = [StoreCoordinator objectWithID:sender.representedObject]; - NSString *url = nil; - if ([obj isKindOfClass:[FeedItem class]]) { - url = [(FeedItem*)obj link]; - } else if ([obj isKindOfClass:[FeedConfig class]]) { - url = [[(FeedConfig*)obj feed] link]; - } - if (!url || url.length == 0) return; - [[NSWorkspace sharedWorkspace] openURLs:@[[NSURL URLWithString:url]] withAppBundleIdentifier:@"com.apple.Safari" options:NSWorkspaceLaunchDefault additionalEventParamDescriptor:nil launchIdentifiers:nil]; -} - -#pragma mark - Menu Generator - -- (NSMenu*)mainMenu { - NSMenu *menu = [NSMenu new]; - menu.autoenablesItems = NO; -// self.mm = menu; - [self addTitle:@"Pause Updates" selector:@selector(pauseUpdates) key:@"" toMenu:menu]; - [self addTitle:@"Update all feeds" selector:@selector(updateAllFeeds) key:@"" toMenu:menu]; - [self addTitle:@"Open all unread" selector:@selector(openAllUnread) key:@"" toMenu:menu]; - [menu addItem:[NSMenuItem separatorItem]]; - - NSArray *items = [StoreCoordinator sortedFeedConfigItems]; - for (FeedConfig *fc in items) { - [menu addItem:[self menuItemForFeedConfig:fc]]; - } - - [menu addItem:[NSMenuItem separatorItem]]; - [self addTitle:@"Preferences" selector:@selector(openPreferences) key:@"," toMenu:menu]; - [menu addItemWithTitle:@"Quit" action:@selector(terminate:) keyEquivalent:@"q"]; - return menu; -} - -- (void)addTitle:(NSString*)title selector:(SEL)selector key:(NSString*)key toMenu:(NSMenu*)menu { - NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:title action:selector keyEquivalent:key]; - item.target = self; - [menu addItem:item]; -} - -- (NSMenuItem*)menuItemForFeedConfig:(FeedConfig*)fc { - NSMenuItem *item; - if (fc.typ == SEPARATOR) { - item = [NSMenuItem separatorItem]; - } else { - item = [[NSMenuItem alloc] initWithTitle:fc.name action:nil keyEquivalent:@""]; - if (fc.typ == FEED) { - item.submenu = [self menuForFeed:fc.feed]; - item.action = @selector(openFeedURL:); - item.target = self; - item.toolTip = fc.feed.subtitle; - item.enabled = (fc.feed.link.length > 0); - static NSImage *defaultRSSIcon; - if (!defaultRSSIcon) - defaultRSSIcon = [[[RSSIcon iconWithSize:NSMakeSize(16, 16)] autoGradient] image]; - item.image = defaultRSSIcon; - } else { - item.submenu = [self menuForFeedConfig:fc]; - item.image = [NSImage imageNamed:NSImageNameFolder]; - item.image.size = NSMakeSize(16, 16); - } - } - item.representedObject = fc.objectID; - return item; -} - -- (NSMenu*)menuForFeedConfig:(FeedConfig*)parent { - NSMenu *menu = [NSMenu new]; - menu.autoenablesItems = NO; - // TODO: open unread for groups ... - for (FeedConfig *fc in parent.sortedChildren) { - [menu addItem:[self menuItemForFeedConfig:fc]]; - } - return menu; -} - -- (NSMenu*)menuForFeed:(Feed*)feed { - NSMenu *menu = [NSMenu new]; - menu.autoenablesItems = NO; - // TODO: open unread for feed only ... - for (FeedItem *entry in feed.items) { - [menu addItem:[self menuItemForFeedItem:entry]]; - } - return menu; -} - -- (NSMenuItem*)menuItemForFeedItem:(FeedItem*)item { - NSMenuItem *mi = [[NSMenuItem alloc] initWithTitle:item.title action:@selector(openFeedURL:) keyEquivalent:@""]; - mi.target = self; - mi.representedObject = item.objectID; - mi.toolTip = item.subtitle; - mi.enabled = (item.link.length > 0); - return mi; -} - -//- (NSIndexPath*)indexPathForMenu:(NSMenu*)menu { -// NSMenu *parent = menu.supermenu; -// if (parent == nil) { -// return [NSIndexPath new]; -// } else { -// return [[self indexPathForMenu:parent] indexPathByAddingIndex:(NSUInteger)[parent indexOfItemWithSubmenu:menu]]; -// } -//} - -@end diff --git a/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents b/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents index 662afaf..78ed763 100644 --- a/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents +++ b/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents @@ -31,6 +31,7 @@ + @@ -41,7 +42,7 @@ - + \ No newline at end of file diff --git a/baRSS/Preferences/FeedConfig+Ext.h b/baRSS/Preferences/FeedConfig+Ext.h index 1f422ef..cc69de5 100644 --- a/baRSS/Preferences/FeedConfig+Ext.h +++ b/baRSS/Preferences/FeedConfig+Ext.h @@ -22,15 +22,28 @@ #import "FeedConfig+CoreDataClass.h" +@class FeedItem; + @interface FeedConfig (Ext) +/// Enum type to distinguish different @c FeedConfig types typedef enum int16_t { GROUP = 0, FEED = 1, SEPARATOR = 2 } FeedConfigType; +/** + Iteration block for descendants of @c FeedItem. + + @param parent The parent @c FeedConfig where this @c FeedItem belongs to. + @param item Currently processed @c FeedItem. + @return Return @c YES to continue processing. Return @c NO to stop processing and exit early. + */ +typedef BOOL (^FeedConfigRecursiveItemsBlock) (FeedConfig *parent, FeedItem *item); + @property (getter=typ, setter=setTyp:) FeedConfigType typ; @property (readonly) NSArray *sortedChildren; +- (BOOL)descendantFeedItems:(FeedConfigRecursiveItemsBlock)block; - (NSString*)readableRefreshString; - (NSString*)readableDescription; @end diff --git a/baRSS/Preferences/FeedConfig+Ext.m b/baRSS/Preferences/FeedConfig+Ext.m index cc49d8f..69f93da 100644 --- a/baRSS/Preferences/FeedConfig+Ext.m +++ b/baRSS/Preferences/FeedConfig+Ext.m @@ -21,27 +21,52 @@ // SOFTWARE. #import "FeedConfig+Ext.h" +#import "Feed+CoreDataClass.h" @implementation FeedConfig (Ext) +/// Enum tpye getter see @c FeedConfigType +- (FeedConfigType)typ { return (FeedConfigType)self.type; } +/// Enum type setter see @c FeedConfigType +- (void)setTyp:(FeedConfigType)typ { self.type = typ; } -- (FeedConfigType)typ { - return (FeedConfigType)self.type; -} - -- (void)setTyp:(FeedConfigType)typ { - self.type = typ; -} +/** + Sorted children array based on sort order provided in feed settings. + @return Sorted array of @c FeedConfig items. + */ - (NSArray *)sortedChildren { if (self.children.count == 0) return nil; return [self.children sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]]]; } +/** + Iterate over all descendant @c FeedItems in sub groups + + @param block Will yield the current parent config and feed item. Return @c NO to cancel iteration. + @return Returns @c NO if the iteration was canceled early. Otherwise @c YES. + */ +- (BOOL)descendantFeedItems:(FeedConfigRecursiveItemsBlock)block { + if (self.children.count > 0) { + for (FeedConfig *config in self.children) { + if ([config descendantFeedItems:block] == NO) + return NO; + } + } else if (self.feed.items.count > 0) { + for (FeedItem* item in self.feed.items) { + if (block(self, item) == NO) + return NO; + } + } + return YES; +} + +/// @return Formatted string for update interval ( e.g., @c 30m or @c 12h ) - (NSString*)readableRefreshString { return [NSString stringWithFormat:@"%d%c", self.refreshNum, [@"smhdw" characterAtIndex:self.refreshUnit % 5]]; } +/// @return Simplified description of the feed object. - (NSString*)readableDescription { switch (self.typ) { case SEPARATOR: return @"-------------"; diff --git a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m index 235964c..03f928a 100644 --- a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m +++ b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m @@ -191,8 +191,8 @@ } } - (void)loadView { - NSTextField *tf = [NSTextField textFieldWithString:@"New Group"]; - tf.placeholderString = @"New Group"; + NSTextField *tf = [NSTextField textFieldWithString:NSLocalizedString(@"New Group", nil)]; + tf.placeholderString = NSLocalizedString(@"New Group", nil); tf.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin; self.view = tf; } diff --git a/baRSS/Preferences/Preferences.m b/baRSS/Preferences/Preferences.m index cb0645f..ae3666b 100644 --- a/baRSS/Preferences/Preferences.m +++ b/baRSS/Preferences/Preferences.m @@ -56,10 +56,6 @@ [[NSUserDefaults standardUserDefaults] setInteger:selectedIndex forKey:@"preferencesTab"]; } -- (void)windowWillClose:(NSNotification *)notification { - [[NSNotificationCenter defaultCenter] postNotificationName:@"baRSSPreferencesClosed" object:nil]; -} - @end diff --git a/baRSS/Preferences/Preferences.xib b/baRSS/Preferences/Preferences.xib index e098700..96fae40 100644 --- a/baRSS/Preferences/Preferences.xib +++ b/baRSS/Preferences/Preferences.xib @@ -14,7 +14,7 @@ - + diff --git a/baRSS/BarMenu.h b/baRSS/Status Bar Menu/BarMenu.h similarity index 100% rename from baRSS/BarMenu.h rename to baRSS/Status Bar Menu/BarMenu.h diff --git a/baRSS/Status Bar Menu/BarMenu.m b/baRSS/Status Bar Menu/BarMenu.m new file mode 100644 index 0000000..8c55932 --- /dev/null +++ b/baRSS/Status Bar Menu/BarMenu.m @@ -0,0 +1,369 @@ +// +// 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 "BarMenu.h" +#import "StoreCoordinator.h" +#import "DrawImage.h" +#import "Preferences.h" +#import "MenuItemInfo.h" + + +@interface BarMenu() +typedef NS_OPTIONS(NSInteger, MenuItemTag) { + ScopeGlobal = 1, + ScopeGroup = (1<<1), + ScopeLocal = (1<<2), + PauseUpdates = (1<<3), + UpdateFeed = (1<<4), + MarkAllRead = (1<<5), + MarkAllUnread = (1<<6), + OpenAllUnread = (1<<7), +}; + +@property (strong) NSStatusItem *barItem; +@property (strong) Preferences *prefWindow; +@property (weak) NSMenu *mm; +@property (assign) int unreadCountTotal; +@end + + +@implementation BarMenu + +- (instancetype)init { + self = [super init]; + self.barItem = [NSStatusBar.systemStatusBar statusItemWithLength:NSVariableStatusItemLength]; + self.barItem.menu = self.mainMenu; + self.barItem.highlightMode = YES; + [self updateBarIcon]; +// [self donothing]; + return self; +} + +- (void)donothing { + dispatch_async(dispatch_get_main_queue(), ^{ + [self.mm itemAtIndex:4].title = [NSString stringWithFormat:@"%@", [NSDate date]]; + }); + sleep(1); + [self performSelectorInBackground:@selector(donothing) withObject:nil]; +} + +- (void)updateBarIcon { + // TODO: Option: unread count in menubar, Option: highlight color, Option: icon choice + if (self.unreadCountTotal > 0) { + self.barItem.title = [NSString stringWithFormat:@"%d", self.unreadCountTotal]; + self.barItem.image = [[RSSIcon templateIcon:16 tint:[RSSIcon rssOrange]] image]; + } else { + self.barItem.title = @""; + self.barItem.image = [[RSSIcon templateIcon:16 tint:nil] image]; + self.barItem.image.template = YES; + } +} + + +#pragma mark - Menu Generator + + +- (NSMenu*)mainMenu { + NSMenu *menu = [NSMenu new]; + menu.autoenablesItems = NO; + [self addTitle:NSLocalizedString(@"Pause Updates", nil) selector:@selector(pauseUpdates:) key:@"" toMenu:menu tag:PauseUpdates]; + [self addTitle:NSLocalizedString(@"Update all feeds", nil) selector:@selector(updateAllFeeds:) key:@"" toMenu:menu tag:UpdateFeed]; + [menu addItem:[NSMenuItem separatorItem]]; + [self defaultHeaderForMenu:menu scope:ScopeGlobal]; + + for (FeedConfig *fc in [StoreCoordinator sortedFeedConfigItems]) { + [menu addItem:[self menuItemForFeedConfig:fc unread:&_unreadCountTotal]]; + } + + [menu addItem:[NSMenuItem separatorItem]]; + [self addTitle:NSLocalizedString(@"Preferences", nil) selector:@selector(openPreferences) key:@"," toMenu:menu tag:0]; + [menu addItemWithTitle:NSLocalizedString(@"Quit", nil) action:@selector(terminate:) keyEquivalent:@"q"]; + return menu; +} + +- (NSMenuItem*)menuItemForFeedConfig:(FeedConfig*)fc unread:(int*)unread { + NSMenuItem *item; + if (fc.typ == SEPARATOR) { + item = [NSMenuItem separatorItem]; + item.representedObject = [MenuItemInfo withID:fc.objectID]; + return item; + } + int count = 0; + if (fc.typ == FEED) { + item = [self feedItem:fc unread:&count]; + } else if (fc.typ == GROUP) { + item = [self groupItem:fc unread:&count]; + } + *unread += count; + item.representedObject = [MenuItemInfo withID:fc.objectID]; + [item markReadAndUpdateTitle:-count]; + [self updateMenuHeader:item.submenu hasUnread:(count > 0)]; + return item; +} + +- (NSMenuItem*)feedItem:(FeedConfig*)fc unread:(int*)unread { + static NSImage *defaultRSSIcon; + if (!defaultRSSIcon) + defaultRSSIcon = [[[RSSIcon iconWithSize:NSMakeSize(16, 16)] autoGradient] image]; + + NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:fc.name action:@selector(openFeedURL:) keyEquivalent:@""]; + item.target = self; + item.submenu = [self defaultHeaderForMenu:nil scope:ScopeLocal]; + for (FeedItem *obj in fc.feed.items) { + if (obj.unread) ++(*unread); + [item.submenu addItem:[self feedEntryItem:obj]]; + } + item.toolTip = fc.feed.subtitle; + item.enabled = (fc.feed.items.count > 0); + item.image = defaultRSSIcon; + return item; +} + +- (NSMenuItem*)groupItem:(FeedConfig*)fc unread:(int*)unread { + static NSImage *groupIcon; + if (!groupIcon) { + groupIcon = [NSImage imageNamed:NSImageNameFolder]; + groupIcon.size = NSMakeSize(16, 16); + } + NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:fc.name action:nil keyEquivalent:@""]; + item.image = groupIcon; + item.submenu = [self defaultHeaderForMenu:nil scope:ScopeGroup]; + for (FeedConfig *obj in fc.sortedChildren) { + NSMenuItem *subItem = [self menuItemForFeedConfig:obj unread:unread]; +// *unread += [(MenuItemInfo*)subItem.representedObject unreadCount]; + [item.submenu addItem:subItem]; + } + return item; +} + +- (NSMenuItem*)feedEntryItem:(FeedItem*)item { + NSMenuItem *mi = [[NSMenuItem alloc] initWithTitle:item.title action:@selector(openFeedURL:) keyEquivalent:@""]; + mi.target = self; + mi.representedObject = [MenuItemInfo withID:item.objectID unread:(item.unread ? 1 : 0)]; + mi.toolTip = item.subtitle; + mi.enabled = (item.link.length > 0); + mi.state = (item.unread ? NSControlStateValueOn : NSControlStateValueOff); + mi.tag = ScopeLocal; + return mi; +} + + +/** + Append header items to menu accoring to user preferences. + + @note If @c menu is @c nil a new menu is created and returned. + @param menu The menu where the items should be appended. + @param scope Tag will be concatenated with that scope (Global, Group or Local). + @return Will return the menu item provided or create a new one if menu was @c nil. + */ +- (NSMenu*)defaultHeaderForMenu:(NSMenu*)menu scope:(MenuItemTag)scope { + if (!menu) { + menu = [NSMenu new]; + menu.autoenablesItems = NO; + } + // TODO: hide items according to preferences + [self addTitle:NSLocalizedString(@"Mark all read", nil) selector:@selector(markAllRead:) key:@"" toMenu:menu tag:MarkAllRead | scope]; + [self addTitle:NSLocalizedString(@"Mark all unread", nil) selector:@selector(markAllUnread:) key:@"" toMenu:menu tag:MarkAllUnread | scope]; + [self addTitle:NSLocalizedString(@"Open all unread", nil) selector:@selector(openAllUnread:) key:@"" toMenu:menu tag:OpenAllUnread | scope]; + [menu addItem:[NSMenuItem separatorItem]]; + return menu; +} + +- (void)updateMenuHeader:(NSMenu*)menu hasUnread:(BOOL)flag { +// [menu itemWithTag:MenuItemTag_FeedMarkAllRead].enabled = flag; +// [menu itemWithTag:MenuItemTag_FeedMarkAllUnread].enabled = !flag; +// [menu itemWithTag:MenuItemTag_FeedOpenAllUnread].enabled = flag; +} + +- (void)addTitle:(NSString*)title selector:(SEL)selector key:(NSString*)key toMenu:(NSMenu*)menu tag:(MenuItemTag)tag { + NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:title action:selector keyEquivalent:key]; + item.target = self; + item.tag = tag; + [menu addItem:item]; +} + +//- (NSIndexPath*)indexPathForMenu:(NSMenu*)menu { +// NSMenu *parent = menu.supermenu; +// if (parent == nil) { +// return [NSIndexPath new]; +// } else { +// return [[self indexPathForMenu:parent] indexPathByAddingIndex:(NSUInteger)[parent indexOfItemWithSubmenu:menu]]; +// } +//} + + +#pragma mark - Menu Actions + + +- (void)openPreferences { + if (!self.prefWindow) { + self.prefWindow = [[Preferences alloc] initWithWindowNibName:@"Preferences"]; + self.prefWindow.window.title = [NSString stringWithFormat:@"%@ %@", NSProcessInfo.processInfo.processName, + NSLocalizedString(@"Preferences", nil)]; + // one time token to set reference to nil, which will release window + NSNotificationCenter * __weak center = [NSNotificationCenter defaultCenter]; + id __block token = [center addObserverForName:NSWindowWillCloseNotification object:self.prefWindow.window queue:nil usingBlock:^(NSNotification *note) { + self.prefWindow = nil; + [center removeObserver:token]; + }]; + } + [NSApp activateIgnoringOtherApps:YES]; + [self.prefWindow showWindow:nil]; +} + + +- (void)pauseUpdates:(NSMenuItem*)sender { + NSLog(@"1pause"); +} + +- (void)updateAllFeeds:(NSMenuItem*)sender { + NSLog(@"1update all"); +} + +- (void)openAllUnread:(NSMenuItem*)sender { + __block int maxItemCount = INT_MAX; + NSMutableArray *urls = [NSMutableArray array]; + [self siblingsDescendantFeedConfigs:sender block:^BOOL(FeedConfig *parent, FeedItem *item) { + if (maxItemCount <= 0) + return NO; // stop further processing + if (item.unread && item.link.length > 0) { + [urls addObject:[NSURL URLWithString:item.link]]; + item.unread = NO; + --maxItemCount; + } + return YES; + }]; + maxItemCount = INT_MAX; + int total = [sender siblingsDescendantItemInfo:^int(NSMenuItem *item, MenuItemInfo *info, int count) { + if (maxItemCount <= 0) + return -1; // stop further processing + if (info.hasUnread) { + [item markReadAndUpdateTitle:count]; + --maxItemCount; + return count; + } + return 0; + } unreadEntriesOnly:YES]; + [self updateAcestors:sender markRead:total]; + [self openURLsWithPreferredBrowser:urls]; +} + +- (void)markAllRead:(NSMenuItem*)sender { + [self siblingsDescendantFeedConfigs:sender block:^BOOL(FeedConfig *parent, FeedItem *item) { + if (item.unread) + item.unread = NO; + return YES; + }]; + int total = [sender siblingsDescendantItemInfo:^int(NSMenuItem *item, MenuItemInfo *info, int count) { + if (info.hasUnread) { + [item markReadAndUpdateTitle:count]; + return count; + } + return 0; + } unreadEntriesOnly:YES]; + [self updateAcestors:sender markRead:total]; +} + +- (void)markAllUnread:(NSMenuItem*)sender { + [self siblingsDescendantFeedConfigs:sender block:^BOOL(FeedConfig *parent, FeedItem *item) { + if (item.unread == NO) + item.unread = YES; + return YES; + }]; + int total = [sender siblingsDescendantItemInfo:^int(NSMenuItem *item, MenuItemInfo *info, int count) { + if (count > info.unreadCount) + [item markReadAndUpdateTitle:(info.unreadCount - count)]; + return count; + } unreadEntriesOnly:NO]; + [self updateAcestors:sender markRead:([self getAncestorUnreadCount:sender] - total)]; +} + +- (void)openFeedURL:(NSMenuItem*)sender { + MenuItemInfo *info = sender.representedObject; + if (![info isKindOfClass:[MenuItemInfo class]]) return; + + id obj = [StoreCoordinator objectWithID:info.objID]; + NSString *url = nil; + if ([obj isKindOfClass:[FeedConfig class]]) { + url = [[(FeedConfig*)obj feed] link]; + } else if ([obj isKindOfClass:[FeedItem class]]) { + FeedItem *feed = obj; + url = [feed link]; + if (info.hasUnread) { + feed.unread = NO; + [sender markReadAndUpdateTitle:1]; + [self updateAcestors:sender markRead:1]; + } + } + if (!url || url.length == 0) return; + [self openURLsWithPreferredBrowser:@[[NSURL URLWithString:url]]]; +} + +- (void)openURLsWithPreferredBrowser:(NSArray*)urls { + if (urls.count == 0) return; +// [[NSWorkspace sharedWorkspace] openURLs:urls withAppBundleIdentifier:@"com.apple.Safari" options:NSWorkspaceLaunchDefault additionalEventParamDescriptor:nil launchIdentifiers:nil]; +} + + +#pragma mark - Iterating over items and propagating unread count + + +- (FeedConfig*)requestFeedConfigForMenuItem:(NSMenuItem*)sender { + MenuItemInfo *info = sender.representedObject; + if (![info isKindOfClass:[MenuItemInfo class]]) + return nil; + id obj = [StoreCoordinator objectWithID:info.objID]; + if (![obj isKindOfClass:[FeedConfig class]]) + return nil; + return obj; +} + +- (void)siblingsDescendantFeedConfigs:(NSMenuItem*)sender block:(FeedConfigRecursiveItemsBlock)block { + if (sender.parentItem) { + [[self requestFeedConfigForMenuItem:sender.parentItem] descendantFeedItems:block]; + } else { + // Sadly we can't just fetch the list of FeedItems since it is not ordered (in case open 10 at a time) + for (FeedConfig *config in [StoreCoordinator sortedFeedConfigItems]) { + if ([config descendantFeedItems:block] == NO) + break; + } + } +} + +- (void)updateAcestors:(NSMenuItem*)sender markRead:(int)count { + [sender markAncestorsRead:count]; + self.unreadCountTotal -= count; + if (self.unreadCountTotal < 0) { + NSLog(@"Should never happen. Global unread count < 0"); + self.unreadCountTotal = 0; + } + [self updateBarIcon]; +} + +- (int)getAncestorUnreadCount:(NSMenuItem*)sender { + MenuItemInfo *info = sender.parentItem.representedObject; + if ([info isKindOfClass:[MenuItemInfo class]]) + return info.unreadCount; + return self.unreadCountTotal; +} + +@end diff --git a/baRSS/Status Bar Menu/MenuItemInfo.h b/baRSS/Status Bar Menu/MenuItemInfo.h new file mode 100644 index 0000000..b31705b --- /dev/null +++ b/baRSS/Status Bar Menu/MenuItemInfo.h @@ -0,0 +1,60 @@ +// +// 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 + +/** + Object storing the corresponding Core Data object id and an unread counter. + */ +@interface MenuItemInfo : NSObject +/// Core Data store ID +@property (strong) NSManagedObjectID *objID; +/// internal counter used to sum the unread count of all sub items +@property (assign) int unreadCount; +/// internal flag whether unread count is displayed in parenthesis +@property (assign) BOOL countInTitle; + ++ (instancetype)withID:(NSManagedObjectID*)oid; ++ (instancetype)withID:(NSManagedObjectID*)oid unread:(int)count; + +- (BOOL)hasUnread; +- (void)markRead:(int)count; +@end + + +@interface NSMenuItem (MenuItemInfo) +/** + Iteration block for descendants of @c NSMenuItem. + + @param count The number of sub-elements contained in that @c NSMenuItem. 1 for @c FeedItems at the deepest layer. + Otherwise the number of (updated) descendants. + @return Return how many elements are updated in this block execution. If none were changed return @c 0. + If execution should be stopped early, return @c -1. + */ +typedef int (^MenuItemInfoRecursiveBlock) (NSMenuItem *item, MenuItemInfo *info, int count); + +- (int)unreadCount; +- (int)siblingsDescendantItemInfo:(MenuItemInfoRecursiveBlock)block unreadEntriesOnly:(BOOL)flag; +- (void)markAncestorsRead:(int)count; +- (void)markReadAndUpdateTitle:(int)count; +@end + diff --git a/baRSS/Status Bar Menu/MenuItemInfo.m b/baRSS/Status Bar Menu/MenuItemInfo.m new file mode 100644 index 0000000..2cbd537 --- /dev/null +++ b/baRSS/Status Bar Menu/MenuItemInfo.m @@ -0,0 +1,158 @@ +// +// 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 "MenuItemInfo.h" + + +@implementation MenuItemInfo +/// @return Info with unreadCount = 0 ++ (instancetype)withID:(NSManagedObjectID*)oid { + return [MenuItemInfo withID:oid unread:0]; +} + ++ (instancetype)withID:(NSManagedObjectID*)oid unread:(int)count { + MenuItemInfo *info = [MenuItemInfo new]; + info.objID = oid; + info.unreadCount = count; + return info; +} + +/// @return @c YES if (unreadCount > 0) +- (BOOL)hasUnread { + return self.unreadCount > 0; +} + +/// set: unreadCount -= count +- (void)markRead:(int)count { + if (count > self.unreadCount) { + NSLog(@"should never happen, trying to set an unread count below zero"); + self.unreadCount = 0; + } else { + self.unreadCount -= count; + } +} + +@end + + + +@implementation NSMenuItem (MenuItemInfo) + +/** + Call represented object and retrieve the unread count from info. + + @return Unread count stored in menu info. + */ +- (int)unreadCount { + MenuItemInfo *info = self.representedObject; + if (![info isKindOfClass:[MenuItemInfo class]]) return 0; + return info.unreadCount; +} + +/** + Recursively iterate over submenues and children. Count aggregated element edits. + + @param block Will be called for each @c NSMenuItem sub-element where @c representedObject is set to a @c MenuItemInfo. + @param flag If set to @c YES, recursive calls will be skipped for submenus that contain soleily read elements. + @return The number of changed elements in total. + */ +- (int)descendantItemInfo:(MenuItemInfoRecursiveBlock)block unreadEntriesOnly:(BOOL)flag { + MenuItemInfo *info = self.representedObject; + if (![info isKindOfClass:[MenuItemInfo class]]) return 0; + if (flag && !info.hasUnread) return 0; + if (self.isSeparatorItem) return 0; + + int countItems = 1; // deepest entry, FeedItem + if (self.hasSubmenu) { + countItems = 0; + for (NSMenuItem *child in self.submenu.itemArray) { + int c = [child descendantItemInfo:block unreadEntriesOnly:flag]; + if (c < 0) break; + countItems += c; + } + } + return block(self, info, countItems); +} + +/** + Recursively iterate over siblings and all contained children. Count aggregated element edits. + + @param block Will be called for each @c NSMenuItem sub-element where @c representedObject is set to a @c MenuItemInfo. + @param flag If set to @c YES, recursive calls will be skipped for submenus that contain soleily read elements. + @return The number of changed elements in total. + */ +- (int)siblingsDescendantItemInfo:(MenuItemInfoRecursiveBlock)block unreadEntriesOnly:(BOOL)flag { + int markedTotal = 0; + for (NSMenuItem *sibling in self.menu.itemArray) { + int marked = [sibling descendantItemInfo:block unreadEntriesOnly:flag]; + if (marked < 0) break; + markedTotal += marked; + } + return markedTotal; +} + +/** + Recursively propagate unread count to ancestor menu items. + + @note Does not update the current item, only the ancestors. + @param count The amount by which the counter is adjusted. + If negative the items will be marked as unread. + */ +- (void)markAncestorsRead:(int)count { + NSMenuItem *parent = self.parentItem; + while (parent.representedObject) { + [parent markReadAndUpdateTitle:count]; + parent = parent.parentItem; + } +} + +/** + Update internal unread counter and append unread count to title. + + @note Count may be negative to mark items as unread. + @warning Does not check if @c representedObject is set accordingly + @param count The amount by which the counter is adjusted. + If negative the items will be marked as unread. + */ +- (void)markReadAndUpdateTitle:(int)count { + MenuItemInfo *info = self.representedObject; + if (!self.hasSubmenu) { + [info markRead:count]; + self.state = (info.hasUnread ? NSControlStateValueOn : NSControlStateValueOff); + } else { + int countBefore = info.unreadCount; + [info markRead:count]; + if (info.countInTitle) { + int digitsBefore = (int)log10f(countBefore) + 1; + NSInteger index = (NSInteger)self.title.length - digitsBefore - 3; // " (%d)" + if (index < 0) index = 0; + self.title = [self.title substringToIndex:(NSUInteger)index]; // remove old count + info.countInTitle = NO; + } + if (info.unreadCount > 0) { + self.title = [self.title stringByAppendingFormat:@" (%d)", info.unreadCount]; + info.countInTitle = YES; + } + } +} + +@end