Refactoring Part 2: Unread count in Feed instead of Config

This commit is contained in:
relikd
2018-12-04 01:01:28 +01:00
parent 6223d1a169
commit ae4700faca
19 changed files with 560 additions and 445 deletions

View File

@@ -34,7 +34,7 @@ ToDo
- [ ] Choose status bar icon?
- [ ] Tick mark feed items based on prefs
- [ ] Open a few links (# editable)
- [ ] Performance: Update menu partially
- [x] Performance: Update menu partially
- [x] Start on login
- [x] Make it system default application
- [ ] Display license info (e.g., RSXML)
@@ -45,10 +45,11 @@ ToDo
- [ ] Status menu
- [ ] Update menu header after mark (un)read
- [x] Update menu header after mark (un)read
- [ ] Pause updates functionality
- [x] Update all feeds functionality
- [ ] Hold only relevant information in memory
- [x] Hold only relevant information in memory
- [ ] Icon for paused / no internet state
- [ ] Edit feed
@@ -68,7 +69,7 @@ ToDo
- [ ] Translate text to different languages
- [x] Automatically update feeds with chosen interval
- [x] Reuse ETag and Modification date
- ~~[ ] Append only new items, keep sorting~~
- [x] Append only new items, keep sorting
- [x] Delete old ones eventually
- [x] Pause on internet connection lost
- [ ] Download with ephemeral url session?
@@ -85,7 +86,7 @@ ToDo
- [ ] Notification Center
- [ ] Sleep timer. (e.g., disable updates during working hours)
- [ ] Pure image feed? (show images directly in menu)
- [ ] Infinite storage. (load more button)
- ~~[ ] Infinite storage. (load more button)~~
- [ ] Automatically open feed items?
- [ ] Per feed launch application (e.g., for podcasts)
- [ ] Per group setting to exclude unread count from menu bar

View File

@@ -25,8 +25,9 @@
@class RSParsedFeed;
@interface Feed (Ext)
+ (Feed*)feedFromRSS:(RSParsedFeed*)obj inContext:(NSManagedObjectContext*)context alreadyRead:(NSArray<NSString*>*)urls unread:(int*)unreadCount;
- (NSArray<NSString*>*)alreadyReadURLs;
- (void)markAllItemsRead;
- (void)markAllItemsUnread;
- (void)updateWithRSS:(RSParsedFeed*)obj;
- (NSArray<FeedItem*>*)sortedArticles;
- (int)markAllItemsRead;
- (int)markAllItemsUnread;
@end

View File

@@ -27,8 +27,50 @@
@implementation Feed (Ext)
+ (FeedItem*)createFeedItemFrom:(RSParsedArticle*)entry inContext:(NSManagedObjectContext*)context {
FeedItem *b = [[FeedItem alloc] initWithEntity:FeedItem.entity insertIntoManagedObjectContext:context];
/**
Replace feed title, subtitle and link (if changed). Also adds new articles and removes old ones.
*/
- (void)updateWithRSS:(RSParsedFeed*)obj {
if (![self.title isEqualToString:obj.title]) self.title = obj.title;
if (![self.subtitle isEqualToString:obj.subtitle]) self.subtitle = obj.subtitle;
if (![self.link isEqualToString:obj.link]) self.link = obj.link;
NSMutableSet<NSString*> *urls = [[self.items valueForKeyPath:@"link"] mutableCopy];
if ([self addMissingArticles:obj updateLinks:urls]) // will remove links in 'urls' that should be kept
[self deleteArticlesWithLink:urls]; // remove old, outdated articles
}
/**
Append new articles and increment their sortIndex. Update article counter and unread counter on the way.
@param urls Input will be used to identify new articles. Output will contain URLs that aren't present in the feed anymore.
@return @c YES if new items were added, @c NO otherwise.
*/
- (BOOL)addMissingArticles:(RSParsedFeed*)obj updateLinks:(NSMutableSet<NSString*>*)urls {
int latestID = [[self.items valueForKeyPath:@"@max.sortIndex"] intValue];
__block int newOnes = 0;
[obj.articles enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(RSParsedArticle * _Nonnull article, BOOL * _Nonnull stop) {
// reverse enumeration ensures correct article order
if ([urls containsObject:article.link]) {
[urls removeObject:article.link];
} else {
newOnes += 1;
[self insertArticle:article atIndex:latestID + newOnes];
}
}];
if (newOnes == 0) return NO;
self.articleCount += newOnes;
self.unreadCount += newOnes; // new articles are by definition unread
return YES;
}
/**
Create article based on input and insert into core data storage.
*/
- (void)insertArticle:(RSParsedArticle*)entry atIndex:(int)idx {
FeedItem *b = [[FeedItem alloc] initWithEntity:FeedItem.entity insertIntoManagedObjectContext:self.managedObjectContext];
b.sortIndex = (int32_t)idx;
b.unread = YES;
b.guid = entry.guid;
b.title = entry.title;
b.abstract = entry.abstract;
@@ -36,54 +78,72 @@
b.author = entry.author;
b.link = entry.link;
b.published = entry.datePublished;
return b;
[self addItemsObject:b];
}
+ (Feed*)feedFromRSS:(RSParsedFeed*)obj inContext:(NSManagedObjectContext*)context alreadyRead:(NSArray<NSString*>*)urls unread:(int*)unreadCount {
Feed *a = [[Feed alloc] initWithEntity:Feed.entity insertIntoManagedObjectContext:context];
a.title = obj.title;
a.subtitle = obj.subtitle;
a.link = obj.link;
for (RSParsedArticle *article in obj.articles) {
FeedItem *b = [self createFeedItemFrom:article inContext:context];
if ([urls containsObject:b.link]) {
b.unread = NO;
} else {
*unreadCount += 1;
}
[a addItemsObject:b];
}
return a;
}
- (NSArray<NSString*>*)alreadyReadURLs {
if (!self.items || self.items.count == 0) return nil;
NSMutableArray<NSString*> *mArr = [NSMutableArray arrayWithCapacity:self.items.count];
for (FeedItem *f in self.items) {
if (!f.unread) {
[mArr addObject:f.link];
/**
Delete all items where @c link matches one of the URLs in the @c NSSet.
*/
- (void)deleteArticlesWithLink:(NSMutableSet<NSString*>*)urls {
if (!urls || urls.count == 0)
return;
self.articleCount -= (int32_t)urls.count;
for (FeedItem *item in self.items) {
if ([urls containsObject:item.link]) {
[urls removeObject:item.link];
if (item.unread)
self.unreadCount -= 1;
// TODO: keep unread articles?
[item.managedObjectContext deleteObject:item];
if (urls.count == 0)
break;
}
}
return mArr;
}
- (void)markAllItemsRead {
[self markAllArticlesRead:YES];
/**
@return Articles sorted by attribute @c sortIndex with descending order (newest items first).
*/
- (NSArray<FeedItem*>*)sortedArticles {
if (self.items.count == 0)
return nil;
return [self.items sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:NO]]];
}
- (void)markAllItemsUnread {
[self markAllArticlesRead:NO];
/**
For all articles set @c unread @c = @c NO
@return Change in unread count. (0 or negative number)
*/
- (int)markAllItemsRead {
return [self markAllArticlesRead:YES];
}
- (void)markAllArticlesRead:(BOOL)readFlag {
int count = 0;
/**
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 (FeedItem *i in self.items) {
if (i.unread == readFlag) {
if (i.unread == readFlag)
i.unread = !readFlag;
++count;
}
}
[self.config markUnread:(readFlag ? -count : +count) ancestorsOnly:NO];
int32_t oldCount = self.unreadCount;
int32_t newCount = (readFlag ? 0 : self.articleCount);
if (self.unreadCount != newCount)
self.unreadCount = newCount;
return newCount - oldCount;
}
@end

View File

@@ -33,16 +33,15 @@ typedef enum int16_t {
} FeedConfigType;
@property (getter=typ, setter=setTyp:) FeedConfigType typ;
- (NSArray<FeedConfig*>*)sortedChildren;
- (NSIndexPath*)indexPath;
- (void)markUnread:(int)count ancestorsOnly:(BOOL)flag;
- (void)calculateAndSetScheduled;
- (BOOL)iterateSorted:(BOOL)ordered overDescendantFeeds:(void(^)(Feed*,BOOL*))block;
- (void)setEtag:(NSString*)etag modified:(NSString*)modified;
// Handle children and parents
- (NSString*)indexPathString;
- (NSMutableArray<FeedConfig*>*)allParents;
- (BOOL)iterateSorted:(BOOL)ordered overDescendantFeeds:(void(^)(Feed *feed, BOOL* cancel))block;
// Update feed and meta
- (void)updateRSSFeed:(RSParsedFeed*)obj;
- (void)setEtag:(NSString*)etag modified:(NSString*)modified;
- (void)calculateAndSetScheduled;
// Printing
- (NSString*)readableRefreshString;
- (NSString*)readableDescription;
@end

View File

@@ -31,76 +31,40 @@
/// Enum type setter see @c FeedConfigType
- (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.
*/
#pragma mark - Handle Children And Parents -
/// @return IndexPath as semicolon separated string for sorted children starting with root index.
- (NSString*)indexPathString {
if (self.parent == nil)
return [NSString stringWithFormat:@"%d", self.sortIndex];
return [[self.parent indexPathString] stringByAppendingFormat:@".%d", self.sortIndex];
}
/// @return Children sorted by attribute @c sortIndex (same order as in preferences).
- (NSArray<FeedConfig*>*)sortedChildren {
if (self.children.count == 0)
return nil;
return [self.children sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]]];
}
/// IndexPath for sorted children starting with root index.
- (NSIndexPath*)indexPath {
/// @return @c NSArray of all ancestors: First object is root. Last object is the @c FeedConfig that executed the command.
- (NSMutableArray<FeedConfig*>*)allParents {
if (self.parent == nil)
return [NSIndexPath indexPathWithIndex:(NSUInteger)self.sortIndex];
return [[self.parent indexPath] indexPathByAddingIndex:(NSUInteger)self.sortIndex];
return [NSMutableArray arrayWithObject:self];
NSMutableArray *arr = [self.parent allParents];
[arr addObject:self];
return arr;
}
/**
Change unread counter for all parents recursively. Result will never be negative.
Iterate over all descenden feeds.
@param count If negative, mark items read.
@param ordered If @c YES items are executed in the same order they are listed in the menu. Pass @n NO for a speed-up.
@param block Set @c cancel to @c YES to stop execution of further descendants.
@return @c NO if execution was stopped with @c cancel @c = @c YES in @c block.
*/
- (void)markUnread:(int)count ancestorsOnly:(BOOL)flag {
FeedConfig *par = (flag ? self.parent : self);
while (par) {
[self.managedObjectContext refreshObject:par mergeChanges:YES];
par.unreadCount += count;
NSAssert(par.unreadCount >= 0, @"ERROR ancestorsMarkUnread: Count should never be negative.");
par = par.parent;
}
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged
object:[NSNumber numberWithInt:count]];
}
/// @return Time interval respecting the selected unit. E.g., returns @c 180 for @c '3m'
- (NSTimeInterval)timeInterval {
static const int unit[] = {1, 60, 3600, 86400, 604800}; // smhdw
return self.refreshNum * unit[self.refreshUnit % 5];
}
/// Calculate date from @c refreshNum and @c refreshUnit and set as next scheduled feed update.
- (void)calculateAndSetScheduled {
self.scheduled = [[NSDate date] dateByAddingTimeInterval:[self timeInterval]];
}
/// Update FeedMeta or create new one if needed.
- (void)setEtag:(NSString*)etag modified:(NSString*)modified {
// TODO: move to separate function and add icon download
if (!self.meta) {
self.meta = [[FeedMeta alloc] initWithEntity:FeedMeta.entity insertIntoManagedObjectContext:self.managedObjectContext];
}
self.meta.httpEtag = etag;
self.meta.httpModified = modified;
}
/// Delete any existing feed object and parse new one. Read state will be copied.
- (void)updateRSSFeed:(RSParsedFeed*)obj {
NSArray<NSString*> *readURLs = [self.feed alreadyReadURLs];
int unreadBefore = self.unreadCount;
int unreadAfter = 0;
if (self.feed)
[self.managedObjectContext deleteObject:(NSManagedObject*)self.feed];
if (obj) {
// TODO: update and dont re-create each time
self.feed = [Feed feedFromRSS:obj inContext:self.managedObjectContext alreadyRead:readURLs unread:&unreadAfter];
}
[self markUnread:(unreadAfter - unreadBefore) ancestorsOnly:NO];
}
- (BOOL)iterateSorted:(BOOL)ordered overDescendantFeeds:(void(^)(Feed*,BOOL*))block {
if (self.feed) {
BOOL stopEarly = NO;
@@ -115,8 +79,46 @@
return YES;
}
#pragma mark - Update Feed And Meta -
/// Delete any existing feed object and parse new one. Read state will be copied.
- (void)updateRSSFeed:(RSParsedFeed*)obj {
if (!self.feed) {
self.feed = [[Feed alloc] initWithEntity:Feed.entity insertIntoManagedObjectContext:self.managedObjectContext];
self.feed.indexPath = [self indexPathString];
}
int32_t unreadBefore = self.feed.unreadCount;
[self.feed updateWithRSS:obj];
NSNumber *cDiff = [NSNumber numberWithInteger:self.feed.unreadCount - unreadBefore];
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged object:cDiff];
}
/// Update FeedMeta or create new one if needed.
- (void)setEtag:(NSString*)etag modified:(NSString*)modified {
if (!self.meta) {
self.meta = [[FeedMeta alloc] initWithEntity:FeedMeta.entity insertIntoManagedObjectContext:self.managedObjectContext];
}
if (![self.meta.httpEtag isEqualToString:etag]) self.meta.httpEtag = etag;
if (![self.meta.httpModified isEqualToString:modified]) self.meta.httpModified = modified;
}
/// Calculate date from @c refreshNum and @c refreshUnit and set as next scheduled feed update.
- (void)calculateAndSetScheduled {
self.scheduled = [[NSDate date] dateByAddingTimeInterval:[self timeInterval]];
}
/// @return Time interval respecting the selected unit. E.g., returns @c 180 for @c '3m'
- (NSTimeInterval)timeInterval {
static const int unit[] = {1, 60, 3600, 86400, 604800}; // smhdw
return self.refreshNum * unit[self.refreshUnit % 5];
}
#pragma mark - Printing -
/// @return Formatted string for update interval ( e.g., @c 30m or @c 12h )
- (NSString*)readableRefreshString {
return [NSString stringWithFormat:@"%d%c", self.refreshNum, [@"smhdw" characterAtIndex:self.refreshUnit % 5]];

View File

@@ -26,5 +26,10 @@
static NSString *kNotificationFeedUpdated = @"baRSS-notification-feed-updated";
static NSString *kNotificationNetworkStatusChanged = @"baRSS-notification-network-status-changed";
static NSString *kNotificationTotalUnreadCountChanged = @"baRSS-notification-total-unread-count-changed";
static NSString *kNotificationTotalUnreadCountReset = @"baRSS-notification-total-unread-count-reset";
extern uint64_t dispatch_benchmark(size_t count, void (^block)(void));
//void benchmark(char *desc, dispatch_block_t b){printf("%s: %llu ns\n", desc, dispatch_benchmark(1, b));}
#define benchmark(desc,block) printf(desc": %llu ns\n", dispatch_benchmark(1, block));
#endif /* Constants_h */

View File

@@ -1,11 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="14315.18" systemVersion="17G3025" minimumToolsVersion="Automatic" sourceLanguage="Objective-C" userDefinedModelVersionIdentifier="v1">
<entity name="Feed" representedClassName="Feed" syncable="YES" codeGenerationType="class">
<attribute name="articleCount" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<attribute name="indexPath" optional="YES" attributeType="String" syncable="YES"/>
<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="config" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="FeedConfig" inverseName="feed" inverseEntity="FeedConfig" syncable="YES"/>
<relationship name="items" optional="YES" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="FeedItem" inverseName="feed" inverseEntity="FeedItem" syncable="YES"/>
<relationship name="items" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="FeedItem" inverseName="feed" inverseEntity="FeedItem" syncable="YES"/>
</entity>
<entity name="FeedConfig" representedClassName="FeedConfig" syncable="YES" codeGenerationType="class">
<attribute name="errorCount" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
@@ -15,7 +18,6 @@
<attribute name="scheduled" optional="YES" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
<attribute name="sortIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<attribute name="type" optional="YES" attributeType="Integer 16" defaultValueString="-1" usesScalarValueType="YES" syncable="YES"/>
<attribute name="unreadCount" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<attribute name="url" optional="YES" attributeType="String" syncable="YES"/>
<relationship name="children" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="FeedConfig" inverseName="parent" inverseEntity="FeedConfig" syncable="YES"/>
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Feed" inverseName="config" inverseEntity="Feed" syncable="YES"/>
@@ -29,6 +31,7 @@
<attribute name="guid" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="link" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="published" optional="YES" attributeType="Date" usesScalarValueType="NO" customClassName="NSArray" syncable="YES"/>
<attribute name="sortIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<attribute name="title" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="unread" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="items" inverseEntity="Feed" syncable="YES"/>
@@ -40,9 +43,9 @@
<relationship name="config" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="FeedConfig" inverseName="meta" inverseEntity="FeedConfig" syncable="YES"/>
</entity>
<elements>
<element name="Feed" positionX="-229.09375" positionY="-2.30859375" width="128" height="120"/>
<element name="FeedConfig" positionX="-441.87109375" positionY="-47.390625" width="128" height="240"/>
<element name="FeedItem" positionX="-28.140625" positionY="-17.359375" width="128" height="180"/>
<element name="Feed" positionX="-229.09375" positionY="-2.30859375" width="128" height="165"/>
<element name="FeedConfig" positionX="-441.87109375" positionY="-47.390625" width="128" height="225"/>
<element name="FeedItem" positionX="-28.140625" positionY="-17.359375" width="128" height="195"/>
<element name="FeedMeta" positionX="-234" positionY="72" width="128" height="105"/>
</elements>
</model>

View File

@@ -70,7 +70,7 @@ static BOOL _isReachable = NO;
+ (void)scheduleNextUpdate:(BOOL)forceUpdate {
static NSTimer *_updateTimer;
@synchronized (_updateTimer) {
@synchronized (_updateTimer) { // TODO: dig into analyzer warning
if (_updateTimer) {
[_updateTimer invalidate];
_updateTimer = nil;
@@ -112,7 +112,7 @@ static BOOL _isReachable = NO;
}
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
[StoreCoordinator saveContext:childContext andParent:YES];
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:nil];
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:[list valueForKeyPath:@"objectID"]];
[childContext reset];
childContext = nil;
[self scheduleNextUpdate:NO]; // after forced update, continue regular cycle

View File

@@ -111,6 +111,7 @@
if (self.shouldDeletePrevArticles) {
[item updateRSSFeed:self.feedResult];
[item setEtag:self.httpEtag modified:self.httpDate];
// TODO: add icon download
}
if ([item.managedObjectContext hasChanges]) {
self.objectIsModified = YES;

View File

@@ -26,6 +26,7 @@
#import "ModalFeedEdit.h"
#import "DrawImage.h"
#import "StoreCoordinator.h"
#import "Constants.h"
@interface SettingsFeeds () <ModalEditDelegate>
@property (weak) IBOutlet NSOutlineView *outlineView;
@@ -77,11 +78,14 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
- (IBAction)remove:(id)sender {
[self.undoManager beginUndoGrouping];
for (NSIndexPath *path in self.dataStore.selectionIndexPaths)
[self incrementIndicesBy:-1 forSubsequentNodes:path];
NSArray<NSTreeNode*> *parentNodes = [self.dataStore.selectedNodes valueForKeyPath:@"parentNode"];
[self.dataStore remove:sender];
for (NSTreeNode *parent in parentNodes) {
[self restoreOrderingAndIndexPathStr:parent];
}
[self.undoManager endUndoGrouping];
[self saveChanges];
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
}
- (IBAction)doubleClickOutlineView:(NSOutlineView*)sender {
@@ -126,48 +130,58 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
}];
}
/// Called after an item was modified. May be called twice if download was still in progress.
- (void)modalDidUpdateFeedConfig:(FeedConfig*)config {
[self saveChanges]; // TODO: adjust total count
[self saveChanges];
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
}
#pragma mark - Helper -
/// Insert @c FeedConfig item either after current selection or inside selected folder (if expanded)
- (FeedConfig*)insertSortedItemAtSelection {
NSIndexPath *selectedIndex = [self.dataStore selectionIndexPath];
if (selectedIndex == NULL)
selectedIndex = [NSIndexPath new];
NSIndexPath *insertIndex = selectedIndex;
FeedConfig *selected = [[[self.dataStore arrangedObjects] descendantNodeAtIndexPath:selectedIndex] representedObject];
NSUInteger lastIndex = (selected ? selected.children.count : self.dataStore.arrangedObjects.childNodes.count);
BOOL groupSelected = (selected.typ == GROUP);
if (!groupSelected) {
lastIndex = (NSUInteger)selected.sortIndex + 1; // insert after selection
insertIndex = [insertIndex indexPathByRemovingLastIndex];
[self incrementIndicesBy:+1 forSubsequentNodes:selectedIndex];
--selected.sortIndex; // insert after selection
}
FeedConfig *newItem = [[FeedConfig alloc] initWithEntity:FeedConfig.entity insertIntoManagedObjectContext:self.dataStore.managedObjectContext];
[self.dataStore insertObject:newItem atArrangedObjectIndexPath:[insertIndex indexPathByAddingIndex:lastIndex]];
// First insert, then parent, else troubles
newItem.sortIndex = (int32_t)lastIndex;
newItem.parent = (groupSelected ? selected : selected.parent);
NSTreeNode *selection = [[self.dataStore selectedNodes] firstObject];
NSIndexPath *pth = nil;
if (!selection) { // append to root
pth = [NSIndexPath indexPathWithIndex:[self.dataStore arrangedObjects].childNodes.count]; // or 0 to append at front
} else if ([self.outlineView isItemExpanded:selection]) { // append to group (if open)
pth = [selection.indexPath indexPathByAddingIndex:0]; // or 'selection.childNodes.count' to append at end
} else { // append before / after selected item
pth = selection.indexPath;
// remove the two lines below to insert infront of selection (instead of after selection)
NSUInteger lastIdx = [pth indexAtPosition:pth.length - 1];
pth = [[pth indexPathByRemovingLastIndex] indexPathByAddingIndex:lastIdx + 1];
}
[self.dataStore insertObject:newItem atArrangedObjectIndexPath:pth];
if (pth.length > 2) { // some subfolder; not root folder (has parent!)
NSTreeNode *parentNode = [[self.dataStore arrangedObjects] descendantNodeAtIndexPath:pth].parentNode;
newItem.parent = parentNode.representedObject;
[self restoreOrderingAndIndexPathStr:parentNode];
} else {
[self restoreOrderingAndIndexPathStr:[self.dataStore arrangedObjects]]; // .parent = nil
}
return newItem;
}
#pragma mark - Import & Export of Data
- (void)incrementIndicesBy:(int)val forSubsequentNodes:(NSIndexPath*)path {
NSIndexPath *parentPath = [path indexPathByRemovingLastIndex];
NSTreeNode *root = [self.dataStore arrangedObjects];
if (parentPath.length > 0)
root = [root descendantNodeAtIndexPath:parentPath];
for (NSUInteger i = [path indexAtPosition:path.length - 1]; i < root.childNodes.count; i++) {
FeedConfig *conf = [root.childNodes[i] representedObject];
conf.sortIndex += val;
/// Loop over all descendants and update @c sortIndex @c (FeedConfig) as well as all @c indexPath @c (Feed)
- (void)restoreOrderingAndIndexPathStr:(NSTreeNode*)parent {
NSArray<NSTreeNode*> *children = parent.childNodes;
for (NSUInteger i = 0; i < children.count; i++) {
NSTreeNode *n = [children objectAtIndex:i];
FeedConfig *fc = n.representedObject;
// Re-calculate sort index for all affected parents
if (fc.sortIndex != (int32_t)i)
fc.sortIndex = (int32_t)i;
// Re-calculate index path for all contained feed items
[fc iterateSorted:NO overDescendantFeeds:^(Feed *feed, BOOL *cancel) {
NSString *pthStr = [feed.config indexPathString];
if (![feed.indexPath isEqualToString:pthStr])
feed.indexPath = pthStr;
}];
}
}
@@ -196,49 +210,20 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
}
- (BOOL)outlineView:(NSOutlineView *)outlineView acceptDrop:(id <NSDraggingInfo>)info item:(id)item childIndex:(NSInteger)index {
NSArray<NSTreeNode *> *dstChildren = [item childNodes];
if (!item || !dstChildren)
dstChildren = [self.dataStore arrangedObjects].childNodes;
NSTreeNode *destParent = (item != nil ? item : [self.dataStore arrangedObjects]);
NSUInteger idx = (NSUInteger)index;
if (index == -1) // drag items on folder or root drop
idx = destParent.childNodes.count;
NSIndexPath *dest = [destParent indexPath];
BOOL isFolderDrag = (index == -1);
NSUInteger insertIndex = (isFolderDrag ? dstChildren.count : (NSUInteger)index);
// index where the items will be moved to, but not final since items above can vanish
NSIndexPath *dest = [item indexPath];
if (!dest) dest = [NSIndexPath indexPathWithIndex:insertIndex];
else dest = [dest indexPathByAddingIndex:insertIndex];
NSArray<NSTreeNode*> *previousParents = [self.currentlyDraggedNodes valueForKeyPath:@"parentNode"];
[self.dataStore moveNodes:self.currentlyDraggedNodes toIndexPath:[dest indexPathByAddingIndex:idx]];
// decrement index for every item that is dragged from the same location (above the destination)
NSUInteger updateIndex = insertIndex;
for (NSTreeNode *node in self.currentlyDraggedNodes) {
NSIndexPath *nodesPath = [node indexPath];
if ([[nodesPath indexPathByRemovingLastIndex] isEqualTo:[dest indexPathByRemovingLastIndex]] &&
insertIndex > [nodesPath indexAtPosition:nodesPath.length - 1])
{
--updateIndex;
}
}
for (NSUInteger i = self.currentlyDraggedNodes.count; i > 0; i--) { // sorted that way to handle children first
FeedConfig *fc = [self.currentlyDraggedNodes[i - 1] representedObject];
[fc.managedObjectContext refreshObject:fc mergeChanges:YES]; // make sure unreadCount is correct
[fc markUnread:-fc.unreadCount ancestorsOnly:YES];
for (NSTreeNode *node in previousParents) {
[self restoreOrderingAndIndexPathStr:node];
}
[self restoreOrderingAndIndexPathStr:destParent];
// decrement sort indices at source
for (NSTreeNode *node in self.currentlyDraggedNodes)
[self incrementIndicesBy:-1 forSubsequentNodes:[node indexPath]];
// increment sort indices at destination
if (!isFolderDrag)
[self incrementIndicesBy:(int)self.currentlyDraggedNodes.count forSubsequentNodes:dest];
// move items
[self.dataStore moveNodes:self.currentlyDraggedNodes toIndexPath:dest];
// set sort indices for dragged items
for (NSUInteger i = 0; i < self.currentlyDraggedNodes.count; i++) {
FeedConfig *fc = [self.currentlyDraggedNodes[i] representedObject];
fc.sortIndex = (int32_t)(updateIndex + i);
[fc markUnread:fc.unreadCount ancestorsOnly:YES];
}
return YES;
}
@@ -317,15 +302,15 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
- (void)undo:(id)sender {
[self.undoManager undo];
[StoreCoordinator restoreUnreadCount];
[self saveChanges];
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
[self.dataStore rearrangeObjects]; // update ordering
}
- (void)redo:(id)sender {
[self.undoManager redo];
[StoreCoordinator restoreUnreadCount];
[self saveChanges];
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
[self.dataStore rearrangeObjects]; // update ordering
}

View File

@@ -60,7 +60,7 @@
- (IBAction)fixCache:(NSButton *)sender {
[StoreCoordinator deleteUnreferencedFeeds];
[StoreCoordinator restoreUnreadCount];
[StoreCoordinator restoreFeedCountsAndIndexPaths];
}
- (IBAction)changeMenuBarIconSetting:(NSButton*)sender {

View File

@@ -24,4 +24,5 @@
@interface BarMenu : NSObject <NSMenuDelegate>
- (void)updateBarIcon;
- (void)reloadUnreadCountAndUpdateBarIcon;
@end

View File

@@ -27,7 +27,6 @@
#import "Preferences.h"
#import "UserPrefs.h"
#import "NSMenu+Ext.h"
#import "NSMenuItem+Ext.h"
#import "Feed+Ext.h"
#import "Constants.h"
@@ -35,9 +34,9 @@
@interface BarMenu()
@property (strong) NSStatusItem *barItem;
@property (strong) Preferences *prefWindow;
@property (assign) int unreadCountTotal;
@property (strong) NSArray<FeedConfig*> *allFeeds;
@property (strong) NSArray<NSManagedObjectID*> *currentOpenMenu;
@property (assign, atomic) NSInteger unreadCountTotal;
@property (weak) NSMenu *currentOpenMenu;
@property (strong) NSArray<NSManagedObjectID*> *objectIDsForMenu;
@property (strong) NSManagedObjectContext *readContext;
@end
@@ -53,15 +52,13 @@
// Unread counter
self.unreadCountTotal = 0;
[self updateBarIcon];
dispatch_async(dispatch_get_main_queue(), ^{
self.unreadCountTotal = [StoreCoordinator totalNumberOfUnreadFeeds];
[self updateBarIcon];
});
[self reloadUnreadCountAndUpdateBarIcon];
// Register for notifications
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedUpdated:) name:kNotificationFeedUpdated object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(networkChanged:) name:kNotificationNetworkStatusChanged object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(unreadCountChanged:) name:kNotificationTotalUnreadCountChanged object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(reloadUnreadCountAndUpdateBarIcon) name:kNotificationTotalUnreadCountReset object:nil];
[FeedDownload registerNetworkChangeNotification];
[FeedDownload performSelectorInBackground:@selector(scheduleNextUpdate:) withObject:[NSNumber numberWithBool:NO]];
return self;
@@ -72,15 +69,23 @@
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
/**
Update menu bar icon and text according to unread count and user preferences.
*/
#pragma mark - Update Menu Bar Icon -
/// Regardless of current unread count, perform new core data fetch on total unread count and update icon.
- (void)reloadUnreadCountAndUpdateBarIcon {
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 {
// TODO: Option: icon choice
// TODO: Show paused icon if no internet connection
dispatch_async(dispatch_get_main_queue(), ^{
if (self.unreadCountTotal > 0 && [UserPrefs defaultYES:@"globalUnreadCount"]) {
self.barItem.title = [NSString stringWithFormat:@"%d", self.unreadCountTotal];
self.barItem.title = [NSString stringWithFormat:@"%ld", self.unreadCountTotal];
} else {
self.barItem.title = @"";
}
@@ -119,42 +124,62 @@
[self updateBarIcon];
}
/**
Callback method fired when feeds have been updated in the background.
*/
/// Callback method fired when feeds have been updated in the background.
- (void)feedUpdated:(NSNotification*)notify {
if (self.barItem.menu.numberOfItems > 0) {
// update items only if menu is already open (e.g., during background update)
[self.readContext refreshAllObjects]; // because self.allFeeds is the same context
[self recursiveUpdateMenu:self.barItem.menu withFeed:nil];
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
for (NSManagedObjectID *oid in notify.object) {
FeedConfig *fc = [moc objectWithID:oid];
NSMenu *menu = [self fixUnreadCountForSubmenus:fc];
if (!menu || menu.numberOfItems > 0)
[self rebuiltFeedItems:fc.feed inMenu:menu]; // deepest menu level, feed items
}
[self.barItem.menu autoEnableMenuHeader:(self.unreadCountTotal > 0)]; // once per multi-feed update
[moc reset];
}
}
/**
Called recursively for all @c FeedConfig children.
If the projected submenu in @c menu does not exist, all subsequent children are skipped in @c FeedConfig.
The title and unread count is updated for all menu items. @c FeedItem menus are completely re-generated.
Go through all parent menus and reset the menu title and unread count
@param config If @c nil the root object (@c self.allFeeds) is used.
@param config Should contain a @c Feed object in @c config.feed.
@return @c NSMenu containing @c FeedItem. Will be @c nil if user hasn't open the menu yet.
*/
- (void)recursiveUpdateMenu:(NSMenu*)menu withFeed:(FeedConfig*)config {
if (config.feed.items.count > 0) { // deepest menu level, feed items
- (nullable NSMenu*)fixUnreadCountForSubmenus:(FeedConfig*)config {
NSMenu *menu = self.barItem.menu;
for (FeedConfig *conf in [config allParents]) {
NSInteger offset = [menu feedConfigOffset];
NSMenuItem *item = [menu itemAtIndex:offset + conf.sortIndex];
NSInteger unread = [item setTitleAndUnreadCount:conf];
menu = item.submenu;
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 (including: feed items menu)
}
return menu;
}
/**
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)rebuiltFeedItems:(Feed*)feed inMenu:(NSMenu*)menu {
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 scope:ScopeFeed hasUnread:(config.unreadCount > 0)];
for (FeedItem *fi in config.feed.items) {
[self insertDefaultHeaderForAllMenus:menu hasUnread:(feed.unreadCount > 0)];
for (FeedItem *fi in [feed sortedArticles]) {
NSMenuItem *mi = [menu addItemWithTitle:@"" action:@selector(openFeedURL:) keyEquivalent:@""];
mi.target = self;
[mi setFeedItem:fi];
}
} else {
BOOL hasUnread = (config ? config.unreadCount > 0 : self.unreadCountTotal > 0);
NSInteger offset = [menu getFeedConfigOffsetAndUpdateUnread:hasUnread];
for (FeedConfig *child in (config ? config.children : self.allFeeds)) {
NSMenuItem *item = [menu itemAtIndex:offset + child.sortIndex];
[item setTitleAndUnreadCount:child];
if (item.submenu.numberOfItems > 0)
[self recursiveUpdateMenu:[item submenu] withFeed:child];
}
}
}
@@ -162,102 +187,70 @@
#pragma mark - Menu Delegate & Menu Generation -
// Get rid of everything that is not needed when the system bar menu isnt open.
- (void)menuDidClose:(NSMenu*)menu {
if ([menu isMainMenu]) {
self.allFeeds = nil;
[self.readContext reset];
self.readContext = nil;
self.barItem.menu = [NSMenu menuWithDelegate:self];
}
/// @c currentOpenMenu is needed when a background update occurs. In case a feed items menu is open.
- (void)menuWillOpen:(NSMenu *)menu {
self.currentOpenMenu = menu;
}
// If main menu load inital set of items, then find item based on index path.
- (NSInteger)numberOfItemsInMenu:(NSMenu*)menu {
if ([menu isMainMenu]) {
[self.readContext reset]; // will be ignored if nil
self.readContext = [StoreCoordinator createChildContext];
self.allFeeds = [StoreCoordinator sortedFeedConfigItemsInContext:self.readContext];
self.currentOpenMenu = [self.allFeeds valueForKeyPath:@"objectID"];
} else {
FeedConfig *conf = [self configAtIndexPathStr:menu.title];
[self.readContext refreshObject:conf mergeChanges:YES];
self.currentOpenMenu = [(conf.typ == FEED ? conf.feed.items : [conf sortedChildren]) valueForKeyPath:@"objectID"];
}
return (NSInteger)[self.currentOpenMenu count];
/// 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];
}
/**
Find @c FeedConfig item in array @c self.allFeeds that is already loaded.
@param indexString Path as string that is stored in @c NSMenu title
@note Delegate method not used. Here to prevent weird @c NSMenu behavior.
Otherwise, Cmd-Q (Quit) and Cmd-, (Preferences) will traverse all submenus.
Try yourself with @c NSLog() in @c numberOfItemsInMenu: and @c menuDidClose:
*/
- (FeedConfig*)configAtIndexPathStr:(NSString*)indexString {
NSArray<NSString*> *parts = [indexString componentsSeparatedByString:@"."];
NSInteger firstIndex = [[parts objectAtIndex:1] integerValue];
FeedConfig *changing = [self.allFeeds objectAtIndex:(NSUInteger)firstIndex];
for (NSUInteger i = 2; i < parts.count; i++) {
NSInteger childIndex = [[parts objectAtIndex:i] integerValue];
BOOL err = YES;
for (FeedConfig *c in changing.children) {
if (c.sortIndex == childIndex) {
err = NO;
changing = c;
break; // Exit early. Should be faster than sorted children method.
}
}
NSAssert(!err, @"ERROR configAtIndex: Shouldn't happen. Something wrong with indexing.");
}
return changing;
- (BOOL)menuHasKeyEquivalent:(NSMenu *)menu forEvent:(NSEvent *)event target:(id _Nullable __autoreleasing *)target action:(SEL _Nullable *)action {
return NO;
}
// Lazy populate the system bar menus when needed.
/// Perform a core data fatch request, store sorted object ids array and return object count.
- (NSInteger)numberOfItemsInMenu:(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];
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 {
NSManagedObjectID *moid = [self.currentOpenMenu objectAtIndex:(NSUInteger)index];
id obj = [self.readContext objectWithID:moid];
[self.readContext refreshObject:obj mergeChanges:YES];
id obj = [self.readContext objectWithID:[self.objectIDsForMenu objectAtIndex:(NSUInteger)index]];
if ([obj isKindOfClass:[FeedConfig class]]) {
[item setFeedConfig:obj];
if ([(FeedConfig*)obj typ] == FEED) {
item.target = self;
item.action = @selector(openFeedURL:);
}
if ([(FeedConfig*)obj typ] == FEED)
[item setTarget:self action:@selector(openFeedURL:)];
} else if ([obj isKindOfClass:[FeedItem class]]) {
[item setFeedItem:obj];
item.target = self;
item.action = @selector(openFeedURL:);
[item setTarget:self action:@selector(openFeedURL:)];
}
if (menu.numberOfItems == index + 1) {
int unreadCount = self.unreadCountTotal; // if parent == nil
if ([obj isKindOfClass:[FeedItem class]]) {
unreadCount = [[[(FeedItem*)obj feed] config] unreadCount];
} else if ([(FeedConfig*)obj parent]) {
unreadCount = [[(FeedConfig*)obj parent] unreadCount];
}
[self finalizeMenu:menu hasUnread:(unreadCount > 0)];
self.currentOpenMenu = nil;
if (index + 1 == menu.numberOfItems) { // last item of the menu
[self finalizeMenu:menu object:obj];
self.objectIDsForMenu = nil;
[self.readContext reset];
self.readContext = nil;
}
return YES;
}
/**
Add default menu items that are present in each menu as header.
@param flag If @c NO, 'Open all unread' and 'Mark all read' will be disabled.
Add default menu items that are present in each menu as header and disable menu items if necessary
*/
- (void)finalizeMenu:(NSMenu*)menu hasUnread:(BOOL)flag {
BOOL isMainMenu = [menu isMainMenu];
MenuItemTag scope;
if (isMainMenu) scope = ScopeGlobal;
else if ([menu isFeedMenu]) scope = ScopeFeed;
else scope = ScopeGroup;
[menu replaceSeparatorStringsWithActualSeparator];
[self insertDefaultHeaderForAllMenus:menu scope:scope hasUnread:flag];
if (isMainMenu) {
[self insertMainMenuHeader:menu];
- (void)finalizeMenu:(NSMenu*)menu object:(id)obj {
NSInteger unreadCount = self.unreadCountTotal; // if parent == nil
if ([menu isFeedMenu]) {
unreadCount = [(FeedItem*)obj feed].unreadCount;
} else if (![menu isMainMenu]) {
unreadCount = [menu coreDataUnreadCount];
}
[menu replaceSeparatorStringsWithActualSeparator];
[self insertDefaultHeaderForAllMenus:menu hasUnread:(unreadCount > 0)];
if ([menu isMainMenu])
[self insertMainMenuHeader:menu];
}
/**
@@ -265,11 +258,15 @@
@param flag If @c NO, 'Open all unread' and 'Mark all read' will be disabled.
*/
- (void)insertDefaultHeaderForAllMenus:(NSMenu*)menu scope:(MenuItemTag)scope hasUnread:(BOOL)flag {
NSMenuItem *item1 = [self itemTitle:NSLocalizedString(@"Open all unread", nil) selector:@selector(openAllUnread:) tag:TagOpenAllUnread | scope];
- (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:NSLocalizedString(@"Open a few unread (%d)", nil), 3]];
NSMenuItem *item3 = [self itemTitle:NSLocalizedString(@"Mark all read", nil) selector:@selector(markAllReadOrUnread:) tag:TagMarkAllRead | scope];
NSMenuItem *item4 = [self itemTitle:NSLocalizedString(@"Mark all unread", nil) selector:@selector(markAllReadOrUnread:) tag:TagMarkAllUnread | scope];
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;
@@ -285,8 +282,10 @@
Insert default menu items for the main menu only. Like 'Pause Updates', 'Update all feeds', 'Preferences' and 'Quit'.
*/
- (void)insertMainMenuHeader:(NSMenu*)menu {
NSMenuItem *item1 = [self itemTitle:NSLocalizedString(@"Pause Updates", nil) selector:@selector(pauseUpdates:) tag:TagPauseUpdates];
NSMenuItem *item2 = [self itemTitle:NSLocalizedString(@"Update all feeds", nil) selector:@selector(updateAllFeeds:) tag:TagUpdateFeed];
NSMenuItem *item1 = [NSMenuItem itemWithTitle:NSLocalizedString(@"Pause Updates", nil)
action:@selector(pauseUpdates:) target:self tag:TagPauseUpdates];
NSMenuItem *item2 = [NSMenuItem itemWithTitle:NSLocalizedString(@"Update all feeds", nil)
action:@selector(updateAllFeeds:) target:self tag:TagUpdateFeed];
if ([UserPrefs defaultYES:@"globalUpdateAll"] == NO)
item2.hidden = YES;
if (![FeedDownload isNetworkReachable])
@@ -296,23 +295,13 @@
[menu insertItem:[NSMenuItem separatorItem] atIndex:2];
// < feed content >
[menu addItem:[NSMenuItem separatorItem]];
NSMenuItem *prefs = [self itemTitle:NSLocalizedString(@"Preferences", nil) selector:@selector(openPreferences) tag:TagPreferences];
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"];
}
/**
Helper method to generate a new @c NSMenuItem.
*/
- (NSMenuItem*)itemTitle:(NSString*)title selector:(SEL)selector tag:(MenuItemTag)tag {
NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:title action:selector keyEquivalent:@""];
item.target = self;
item.tag = tag;
[item applyUserSettingsDisplay];
return item;
}
#pragma mark - Menu Actions -
@@ -365,23 +354,19 @@
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
[sender iterateSorted:YES inContext:moc overDescendentFeeds:^(Feed *feed, BOOL *cancel) {
int itemSum = 0;
for (FeedItem *i in feed.items) {
if (itemSum >= maxItemCount) {
break;
}
for (FeedItem *i in [feed sortedArticles]) { // TODO: open oldest articles first?
if (maxItemCount <= 0) break;
if (i.unread && i.link.length > 0) {
[urls addObject:[NSURL URLWithString:i.link]];
i.unread = NO;
++itemSum;
feed.unreadCount -= 1;
self.unreadCountTotal -= 1;
maxItemCount -= 1;
}
}
if (itemSum > 0) {
[feed.config markUnread:-itemSum ancestorsOnly:NO];
maxItemCount -= itemSum;
}
*cancel = (maxItemCount <= 0);
}];
[self updateBarIcon];
[self openURLsWithPreferredBrowser:urls];
[StoreCoordinator saveContext:moc andParent:YES];
[moc reset];
@@ -394,9 +379,9 @@
BOOL markRead = ((sender.tag & TagMaskType) == TagMarkAllRead);
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
[sender iterateSorted:NO inContext:moc overDescendentFeeds:^(Feed *feed, BOOL *cancel) {
if (markRead) [feed markAllItemsRead];
else [feed markAllItemsUnread];
self.unreadCountTotal += (markRead ? [feed markAllItemsRead] : [feed markAllItemsUnread]);
}];
[self updateBarIcon];
[StoreCoordinator saveContext:moc andParent:YES];
[moc reset];
}
@@ -416,11 +401,13 @@
if ([obj isKindOfClass:[FeedConfig class]]) {
url = [[(FeedConfig*)obj feed] link];
} else if ([obj isKindOfClass:[FeedItem class]]) {
FeedItem *feed = obj;
url = [feed link];
if (feed.unread) {
feed.unread = NO;
[feed.feed.config markUnread:-1 ancestorsOnly:NO];
FeedItem *item = obj;
url = [item link];
if (item.unread) {
item.unread = NO;
item.feed.unreadCount -= 1;
self.unreadCountTotal -= 1;
[self updateBarIcon];
[StoreCoordinator saveContext:moc andParent:YES];
}
}

View File

@@ -21,12 +21,20 @@
// SOFTWARE.
#import <Cocoa/Cocoa.h>
#import "NSMenuItem+Ext.h"
@interface NSMenu (Ext)
// Generator
+ (instancetype)menuWithDelegate:(id<NSMenuDelegate>)target;
- (instancetype)submenuWithIndex:(int)index isFeed:(BOOL)flag;
- (void)replaceSeparatorStringsWithActualSeparator;
- (instancetype)cleanInstanceCopy;
// Properties
- (BOOL)isMainMenu;
- (BOOL)isFeedMenu;
- (NSInteger)getFeedConfigOffsetAndUpdateUnread:(BOOL)hasUnread;
- (MenuItemTag)scope;
- (NSInteger)feedConfigOffset;
- (NSInteger)coreDataUnreadCount;
// Modify menu
- (void)replaceSeparatorStringsWithActualSeparator;
- (void)autoEnableMenuHeader:(BOOL)hasUnread;
@end

View File

@@ -21,10 +21,13 @@
// SOFTWARE.
#import "NSMenu+Ext.h"
#import "NSMenuItem+Ext.h"
#import "StoreCoordinator.h"
@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;
@@ -32,18 +35,82 @@
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 new];
NSMenu *menu = [NSMenu menuWithDelegate:self.delegate];
menu.title = [NSString stringWithFormat:@"%c%@.%d", (flag ? 'F' : 'G'), self.title, index];
menu.autoenablesItems = NO;
menu.delegate = self.delegate;
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 @c YES if menu is status bar menu.
- (BOOL)isMainMenu {
return [self.title isEqualToString:@"M"];
}
/// @return @c YES if menu contains feed articles only.
- (BOOL)isFeedMenu {
return [self.title characterAtIndex:0] == 'F';
}
/// @return Either @c ScopeGlobal, @c ScopeGroup or @c ScopeFeed.
- (MenuItemTag)scope {
if ([self isFeedMenu]) return ScopeFeed;
if ([self isMainMenu]) return ScopeGlobal;
return ScopeGroup;
}
/// @return Index offset of the first Core Data feed item (may be separator), skipping default header and main menu header.
- (NSInteger)feedConfigOffset {
for (NSInteger i = 0; i < self.numberOfItems; i++) {
if ([[[self itemAtIndex:i] representedObject] isKindOfClass:[NSManagedObjectID class]])
return i;
}
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 -
/// 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;
}
//[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:@"---SEPARATOR---"]) {
if ([oldItem.title isEqualToString:kSeparatorItemTitle]) {
NSMenuItem *newItem = [NSMenuItem separatorItem];
newItem.representedObject = oldItem.representedObject;
[self removeItemAtIndex:i];
@@ -52,50 +119,4 @@
}
}
- (BOOL)isMainMenu {
return [self.title isEqualToString:@"M"];
}
- (BOOL)isFeedMenu {
return [self.title characterAtIndex:0] == 'F';
}
//- (void)iterateMenuItems:(void(^)(NSMenuItem*,BOOL))block atIndexPath:(NSIndexPath*)path {
// NSMenu *m = self;
// for (NSUInteger u = 0; u < path.length; u++) {
// NSUInteger i = [path indexAtPosition:u];
// for (NSMenuItem *item in m.itemArray) {
// if (![item.representedObject isKindOfClass:[NSManagedObjectID class]]) {
// continue; // not a core data item
// }
// if (i == 0) {
// BOOL isFinalItem = (u == path.length - 1);
// block(item, isFinalItem);
// if (isFinalItem) return; // item found!
// m = item.submenu;
// break; // cancel evaluation of remaining items
// }
// i -= 1;
// }
// }
// return; // whenever a menu inbetween is nil (e.g., wasn't set yet)
//}
- (NSInteger)getFeedConfigOffsetAndUpdateUnread:(BOOL)hasUnread {
for (NSInteger i = 0; i < self.numberOfItems; i++) {
NSMenuItem *item = [self itemAtIndex:i];
if ([item.representedObject isKindOfClass:[NSManagedObjectID class]]) {
return i;
} else {
//[item applyUserSettingsDisplay]; // should not change while menu is open
switch (item.tag & TagMaskType) {
case TagOpenAllUnread: case TagMarkAllRead:
item.enabled = hasUnread;
default: break;
}
}
}
return 0;
}
@end

View File

@@ -22,6 +22,8 @@
#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
@@ -45,11 +47,13 @@ typedef NS_OPTIONS(NSInteger, MenuItemTag) {
@class FeedConfig, Feed, FeedItem;
@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)setFeedConfig:(FeedConfig*)config;
- (void)setFeedItem:(FeedItem*)item;
- (void)setTitleAndUnreadCount:(FeedConfig*)config;
- (NSInteger)setTitleAndUnreadCount:(FeedConfig*)config;
- (void)iterateSorted:(BOOL)ordered inContext:(NSManagedObjectContext*)moc overDescendentFeeds:(void(^)(Feed *feed, BOOL *cancel))block;
- (void)applyUserSettingsDisplay;
@end

View File

@@ -39,6 +39,19 @@ typedef NS_ENUM(char, DisplaySetting) {
@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.
*/
@@ -54,17 +67,31 @@ typedef NS_ENUM(char, DisplaySetting) {
}
/**
Set title based on preferences either with or without unread count in parenthesis.
Convenient method to set @c target and @c action simultaneously.
*/
- (void)setTitleAndUnreadCount:(FeedConfig*)config {
if (config.unreadCount > 0 &&
((config.typ == FEED && [UserPrefs defaultYES:@"feedUnreadCount"]) ||
(config.typ == GROUP && [UserPrefs defaultYES:@"groupUnreadCount"])))
{
self.title = [NSString stringWithFormat:@"%@ (%d)", config.name, config.unreadCount];
} else {
self.title = config.name;
- (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:(FeedConfig*)config {
NSInteger uCount = 0;
if (config.typ == FEED && [UserPrefs defaultYES:@"feedUnreadCount"]) {
uCount = config.feed.unreadCount;
} else if (config.typ == GROUP && [UserPrefs defaultYES:@"groupUnreadCount"]) {
uCount = [self.submenu coreDataUnreadCount];
}
self.title = (uCount > 0 ? [NSString stringWithFormat:@"%@ (%ld)", config.name, uCount] : config.name);
return uCount;
}
/**
@@ -73,10 +100,10 @@ typedef NS_ENUM(char, DisplaySetting) {
- (void)setFeedConfig:(FeedConfig*)config {
self.representedObject = config.objectID;
if (config.typ == SEPARATOR) {
self.title = @"---SEPARATOR---";
self.title = kSeparatorItemTitle;
} else {
[self setTitleAndUnreadCount:config];
self.submenu = [self.menu submenuWithIndex:config.sortIndex isFeed:(config.typ == FEED)];
[self setTitleAndUnreadCount:config]; // after submenu is set
if (config.typ == FEED) {
[self configureAsFeed:config];
} else {
@@ -140,7 +167,7 @@ typedef NS_ENUM(char, DisplaySetting) {
/**
@return @c FeedConfig object if @c representedObject contains a valid @c NSManagedObjectID.
*/
- (FeedConfig*)feedConfig:(NSManagedObjectContext*)moc {
- (FeedConfig*)requestConfig:(NSManagedObjectContext*)moc {
if (!self.representedObject || ![self.representedObject isKindOfClass:[NSManagedObjectID class]])
return nil;
FeedConfig *config = [moc objectWithID:self.representedObject];
@@ -157,10 +184,10 @@ typedef NS_ENUM(char, DisplaySetting) {
*/
- (void)iterateSorted:(BOOL)ordered inContext:(NSManagedObjectContext*)moc overDescendentFeeds:(void(^)(Feed*,BOOL*))block {
if (self.parentItem) {
[[self.parentItem feedConfig:moc] iterateSorted:ordered overDescendantFeeds:block];
[[self.parentItem requestConfig:moc] iterateSorted:ordered overDescendantFeeds:block];
} else {
for (NSMenuItem *item in self.menu.itemArray) {
FeedConfig *fc = [item feedConfig:moc];
FeedConfig *fc = [item requestConfig:moc];
if (fc != nil) { // All groups and feeds; Ignore default header
if (![fc iterateSorted:ordered overDescendantFeeds:block])
return;

View File

@@ -27,14 +27,16 @@
@class RSParsedFeed;
@interface StoreCoordinator : NSObject
+ (NSManagedObjectContext*)getMainContext;
// Managing contexts
+ (NSManagedObjectContext*)createChildContext;
+ (void)saveContext:(NSManagedObjectContext*)context andParent:(BOOL)flag;
+ (NSArray<FeedConfig*>*)sortedFeedConfigItemsInContext:(nonnull NSManagedObjectContext*)context;
// Feed update
+ (NSArray<FeedConfig*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc;
+ (NSDate*)nextScheduledUpdate;
+ (int)totalNumberOfUnreadFeeds;
// Feed display
+ (NSInteger)unreadCountForIndexPathString:(NSString*)str;
+ (NSArray*)sortedObjectIDsForParent:(id)parent isFeed:(BOOL)flag inContext:(NSManagedObjectContext*)moc;
// Restore sound state
+ (void)deleteUnreferencedFeeds;
+ (void)restoreUnreadCount;
+ (void)restoreFeedCountsAndIndexPaths;
@end

View File

@@ -26,6 +26,8 @@
@implementation StoreCoordinator
#pragma mark - Managing contexts -
+ (NSManagedObjectContext*)getMainContext {
return [(AppHook*)NSApp persistentContainer].viewContext;
}
@@ -53,20 +55,14 @@
}
}
+ (NSArray<FeedConfig*>*)sortedFeedConfigItemsInContext:(NSManagedObjectContext*)context {
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedConfig.entity.name];
fr.predicate = [NSPredicate predicateWithFormat:@"parent = NULL"]; // %@", parent
fr.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]];
NSError *err;
NSArray *result = [context executeFetchRequest:fr error:&err];
if (err) NSLog(@"%@", err);
return result;
}
#pragma mark - Feed Update -
+ (NSArray<FeedConfig*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc {
// TODO: Get Feed instead of FeedConfig
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedConfig.entity.name];
if (!forceAll) {
fr.predicate = [NSPredicate predicateWithFormat:@"type = %d AND scheduled <= %@", FEED, [NSDate date]];
// when fetching also get those feeds that would need update soon (now + 30s)
fr.predicate = [NSPredicate predicateWithFormat:@"type = %d AND scheduled <= %@", FEED, [NSDate dateWithTimeIntervalSinceNow:+30]];
} else {
fr.predicate = [NSPredicate predicateWithFormat:@"type = %d", FEED];
}
@@ -97,7 +93,9 @@
return fetchResults.firstObject[@"earliestDate"]; // can be nil
}
+ (int)totalNumberOfUnreadFeeds {
#pragma mark - Feed Display -
+ (NSInteger)unreadCountForIndexPathString:(NSString*)str {
// Always get context first, or 'FeedConfig.entity.name' may not be available on app start
NSManagedObjectContext *moc = [self getMainContext];
NSExpression *exp = [NSExpression expressionForFunction:@"sum:"
@@ -107,15 +105,29 @@
[expDesc setExpression:exp];
[expDesc setExpressionResultType:NSInteger32AttributeType];
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedConfig.entity.name];
fr.predicate = [NSPredicate predicateWithFormat:@"type = %d", FEED];
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
if (str && str.length > 0)
fr.predicate = [NSPredicate predicateWithFormat:@"indexPath BEGINSWITH %@", str];
[fr setResultType:NSDictionaryResultType];
[fr setPropertiesToFetch:@[expDesc]];
NSError *err;
NSArray *fetchResults = [moc executeFetchRequest:fr error:&err];
if (err) NSLog(@"%@", err);
return [fetchResults.firstObject[@"totalUnread"] intValue];
return [fetchResults.firstObject[@"totalUnread"] integerValue];
}
+ (NSArray*)sortedObjectIDsForParent:(id)parent isFeed:(BOOL)flag inContext:(NSManagedObjectContext*)moc {
// NSManagedObjectContext *moc = [self getMainContext];
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: (flag ? FeedItem.entity : FeedConfig.entity).name];
fr.predicate = [NSPredicate predicateWithFormat:(flag ? @"feed.config = %@" : @"parent = %@"), parent];
fr.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:!flag]];
[fr setResultType:NSManagedObjectIDResultType];
NSError *err;
NSArray *fetchResults = [moc executeFetchRequest:fr error:&err];
if (err) NSLog(@"%@", err);
return fetchResults;
}
//+ (void)addToSortIndex:(int)num start:(int)index parent:(FeedConfig*)config inContext:(NSManagedObjectContext*)moc {
@@ -142,27 +154,23 @@
if (err) NSLog(@"%@", err);
}
+ (void)restoreUnreadCount {
+ (void)restoreFeedCountsAndIndexPaths {
NSManagedObjectContext *moc = [self getMainContext];
NSError *err;
NSArray *confs = [moc executeFetchRequest:[NSFetchRequest fetchRequestWithEntityName: FeedConfig.entity.name] error:&err];
if (err) NSLog(@"%@", err);
NSArray *feeds = [moc executeFetchRequest:[NSFetchRequest fetchRequestWithEntityName: Feed.entity.name] error:&err];
NSArray *result = [moc executeFetchRequest:[NSFetchRequest fetchRequestWithEntityName: Feed.entity.name] error:&err];
if (err) NSLog(@"%@", err);
[moc performBlock:^{
for (FeedConfig *conf in confs) {
conf.unreadCount = 0;
}
for (Feed *feed in feeds) {
int count = 0;
for (FeedItem *item in feed.items) {
if (item.unread) ++count;
}
FeedConfig *parent = feed.config;
while (parent) {
parent.unreadCount += count;
parent = parent.parent;
}
for (Feed *feed in result) {
int16_t totalCount = (int16_t)feed.items.count;
int16_t unreadCount = (int16_t)[[feed.items valueForKeyPath:@"@sum.unread"] integerValue];
if (feed.articleCount != totalCount)
feed.articleCount = totalCount;
if (feed.unreadCount != unreadCount)
feed.unreadCount = unreadCount; // remember to update global total unread count
NSString *pathStr = [feed.config indexPathString];
if (![feed.indexPath isEqualToString:pathStr])
feed.indexPath = pathStr;
}
}];
}