Refactoring Part 2: Unread count in Feed instead of Config
This commit is contained in:
11
README.md
11
README.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]];
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
|
||||
- (IBAction)fixCache:(NSButton *)sender {
|
||||
[StoreCoordinator deleteUnreferencedFeeds];
|
||||
[StoreCoordinator restoreUnreadCount];
|
||||
[StoreCoordinator restoreFeedCountsAndIndexPaths];
|
||||
}
|
||||
|
||||
- (IBAction)changeMenuBarIconSetting:(NSButton*)sender {
|
||||
|
||||
@@ -24,4 +24,5 @@
|
||||
|
||||
@interface BarMenu : NSObject <NSMenuDelegate>
|
||||
- (void)updateBarIcon;
|
||||
- (void)reloadUnreadCountAndUpdateBarIcon;
|
||||
@end
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user