Feed Statistics View
- Bugfix: group unread count fetch & undo / redo operations - ModalSheet refactored
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
54195886218E1BDB00581B79 /* NSMenu+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54195885218E1BDB00581B79 /* NSMenu+Ext.m */; };
|
54195886218E1BDB00581B79 /* NSMenu+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54195885218E1BDB00581B79 /* NSMenu+Ext.m */; };
|
||||||
54209E942117325100F3B5EF /* DrawImage.m in Sources */ = {isa = PBXBuildFile; fileRef = 54209E932117325100F3B5EF /* DrawImage.m */; };
|
54209E942117325100F3B5EF /* DrawImage.m in Sources */ = {isa = PBXBuildFile; fileRef = 54209E932117325100F3B5EF /* DrawImage.m */; };
|
||||||
543695D8214F1F2700DA979D /* NSMenuItem+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 543695D7214F1F2700DA979D /* NSMenuItem+Ext.m */; };
|
543695D8214F1F2700DA979D /* NSMenuItem+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 543695D7214F1F2700DA979D /* NSMenuItem+Ext.m */; };
|
||||||
|
544936FB21F1E66100DEE9AA /* Statistics.m in Sources */ = {isa = PBXBuildFile; fileRef = 544936FA21F1E66100DEE9AA /* Statistics.m */; };
|
||||||
544B011A2114B41200386E5C /* ModalSheet.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B01192114B41200386E5C /* ModalSheet.m */; };
|
544B011A2114B41200386E5C /* ModalSheet.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B01192114B41200386E5C /* ModalSheet.m */; };
|
||||||
544B011D2114EE9100386E5C /* AppHook.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B011C2114EE9100386E5C /* AppHook.m */; };
|
544B011D2114EE9100386E5C /* AppHook.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B011C2114EE9100386E5C /* AppHook.m */; };
|
||||||
544DCCB9212A2B4D002DBC46 /* RSXML.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 544DCCB8212A2B4D002DBC46 /* RSXML.framework */; };
|
544DCCB9212A2B4D002DBC46 /* RSXML.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 544DCCB8212A2B4D002DBC46 /* RSXML.framework */; };
|
||||||
@@ -87,6 +88,8 @@
|
|||||||
54209E932117325100F3B5EF /* DrawImage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DrawImage.m; sourceTree = "<group>"; };
|
54209E932117325100F3B5EF /* DrawImage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DrawImage.m; sourceTree = "<group>"; };
|
||||||
543695D6214F1F2700DA979D /* NSMenuItem+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSMenuItem+Ext.h"; sourceTree = "<group>"; };
|
543695D6214F1F2700DA979D /* NSMenuItem+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSMenuItem+Ext.h"; sourceTree = "<group>"; };
|
||||||
543695D7214F1F2700DA979D /* NSMenuItem+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSMenuItem+Ext.m"; sourceTree = "<group>"; };
|
543695D7214F1F2700DA979D /* NSMenuItem+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSMenuItem+Ext.m"; sourceTree = "<group>"; };
|
||||||
|
544936F921F1E66100DEE9AA /* Statistics.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Statistics.h; sourceTree = "<group>"; };
|
||||||
|
544936FA21F1E66100DEE9AA /* Statistics.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Statistics.m; sourceTree = "<group>"; };
|
||||||
544B01182114B41200386E5C /* ModalSheet.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ModalSheet.h; sourceTree = "<group>"; };
|
544B01182114B41200386E5C /* ModalSheet.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ModalSheet.h; sourceTree = "<group>"; };
|
||||||
544B01192114B41200386E5C /* ModalSheet.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ModalSheet.m; sourceTree = "<group>"; };
|
544B01192114B41200386E5C /* ModalSheet.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ModalSheet.m; sourceTree = "<group>"; };
|
||||||
544B011B2114EE9100386E5C /* AppHook.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppHook.h; sourceTree = "<group>"; };
|
544B011B2114EE9100386E5C /* AppHook.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppHook.h; sourceTree = "<group>"; };
|
||||||
@@ -174,6 +177,17 @@
|
|||||||
path = "Status Bar Menu";
|
path = "Status Bar Menu";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
544936F721F1E51E00DEE9AA /* Helper */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
54209E922117325100F3B5EF /* DrawImage.h */,
|
||||||
|
54209E932117325100F3B5EF /* DrawImage.m */,
|
||||||
|
544936F921F1E66100DEE9AA /* Statistics.h */,
|
||||||
|
544936FA21F1E66100DEE9AA /* Statistics.m */,
|
||||||
|
);
|
||||||
|
path = Helper;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
544FBD4321064AEB008A260C /* Frameworks */ = {
|
544FBD4321064AEB008A260C /* Frameworks */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -241,8 +255,7 @@
|
|||||||
54FE73CF21220DEC003EAC65 /* StoreCoordinator.m */,
|
54FE73CF21220DEC003EAC65 /* StoreCoordinator.m */,
|
||||||
54ACC29321061E270020715F /* FeedDownload.h */,
|
54ACC29321061E270020715F /* FeedDownload.h */,
|
||||||
54ACC29421061E270020715F /* FeedDownload.m */,
|
54ACC29421061E270020715F /* FeedDownload.m */,
|
||||||
54209E922117325100F3B5EF /* DrawImage.h */,
|
544936F721F1E51E00DEE9AA /* Helper */,
|
||||||
54209E932117325100F3B5EF /* DrawImage.m */,
|
|
||||||
54195880218A05E700581B79 /* Categories */,
|
54195880218A05E700581B79 /* Categories */,
|
||||||
546FC44D2118B357007CC3A3 /* Preferences */,
|
546FC44D2118B357007CC3A3 /* Preferences */,
|
||||||
54ACC28521061B3C0020715F /* Assets.xcassets */,
|
54ACC28521061B3C0020715F /* Assets.xcassets */,
|
||||||
@@ -401,6 +414,7 @@
|
|||||||
546FC44321189975007CC3A3 /* SettingsGeneral.m in Sources */,
|
546FC44321189975007CC3A3 /* SettingsGeneral.m in Sources */,
|
||||||
54ACC29521061E270020715F /* FeedDownload.m in Sources */,
|
54ACC29521061E270020715F /* FeedDownload.m in Sources */,
|
||||||
5477D34E21233C62002BA27F /* FeedGroup+Ext.m in Sources */,
|
5477D34E21233C62002BA27F /* FeedGroup+Ext.m in Sources */,
|
||||||
|
544936FB21F1E66100DEE9AA /* Statistics.m in Sources */,
|
||||||
540F704521B6C16C0022E69D /* FeedMeta+Ext.m in Sources */,
|
540F704521B6C16C0022E69D /* FeedMeta+Ext.m in Sources */,
|
||||||
54ACC28C21061B3C0020715F /* main.m in Sources */,
|
54ACC28C21061B3C0020715F /* main.m in Sources */,
|
||||||
54FE73D3212316CD003EAC65 /* BarMenu.m in Sources */,
|
54FE73D3212316CD003EAC65 /* BarMenu.m in Sources */,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
+ (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context;
|
+ (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context;
|
||||||
+ (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc;
|
+ (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc;
|
||||||
- (void)calculateAndSetIndexPathString;
|
- (void)calculateAndSetIndexPathString;
|
||||||
|
- (void)resetArticleCountAndIndexPathString;
|
||||||
- (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag;
|
- (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag;
|
||||||
// Article properties
|
// Article properties
|
||||||
- (NSArray<FeedArticle*>*)sortedArticles;
|
- (NSArray<FeedArticle*>*)sortedArticles;
|
||||||
|
|||||||
@@ -57,6 +57,17 @@
|
|||||||
self.indexPath = pthStr;
|
self.indexPath = pthStr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reset attributes @c articleCount, @c unreadCount, and @c indexPath.
|
||||||
|
- (void)resetArticleCountAndIndexPathString {
|
||||||
|
int16_t totalCount = (int16_t)self.articles.count;
|
||||||
|
int16_t unreadCount = (int16_t)[[self.articles valueForKeyPath:@"@sum.unread"] integerValue];
|
||||||
|
if (self.articleCount != totalCount)
|
||||||
|
self.articleCount = totalCount;
|
||||||
|
if (self.unreadCount != unreadCount)
|
||||||
|
self.unreadCount = unreadCount; // remember to update global total unread count
|
||||||
|
[self calculateAndSetIndexPathString];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - Update Feed Items -
|
#pragma mark - Update Feed Items -
|
||||||
|
|
||||||
@@ -144,6 +155,8 @@
|
|||||||
fa.author = entry.author;
|
fa.author = entry.author;
|
||||||
fa.link = entry.link;
|
fa.link = entry.link;
|
||||||
fa.published = entry.datePublished;
|
fa.published = entry.datePublished;
|
||||||
|
if (!fa.published)
|
||||||
|
fa.published = entry.dateModified;
|
||||||
[self addArticlesObject:fa];
|
[self addArticlesObject:fa];
|
||||||
return fa;
|
return fa;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,13 @@ static BOOL _nextUpdateIsForced = NO;
|
|||||||
[FeedDownload batchUpdateFeeds:list showErrorAlert:NO finally:^(NSArray<Feed*> *successful, NSArray<Feed*> *failed) {
|
[FeedDownload batchUpdateFeeds:list showErrorAlert:NO finally:^(NSArray<Feed*> *successful, NSArray<Feed*> *failed) {
|
||||||
[self saveContext:moc andPostChanges:successful];
|
[self saveContext:moc andPostChanges:successful];
|
||||||
[moc reset];
|
[moc reset];
|
||||||
|
if (updateAll) { // forced update will also download missing feed icons
|
||||||
|
NSArray<Feed*> *missingIcons = [StoreCoordinator listOfFeedsMissingIconsInContext:moc];
|
||||||
|
[self batchDownloadFavicons:missingIcons replaceExisting:NO finally:^{
|
||||||
|
[self saveContext:moc andPostChanges:successful];
|
||||||
|
[moc reset];
|
||||||
|
}];
|
||||||
|
}
|
||||||
[self resumeUpdates]; // always reset the timer
|
[self resumeUpdates]; // always reset the timer
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
@@ -285,8 +292,13 @@ static BOOL _nextUpdateIsForced = NO;
|
|||||||
}
|
}
|
||||||
dispatch_group_enter(group);
|
dispatch_group_enter(group);
|
||||||
[self parseFeedRequest:[self newRequest:feed.meta] xmlBlock:nil feedBlock:^(RSParsedFeed *rss, NSError *error, NSHTTPURLResponse *response) {
|
[self parseFeedRequest:[self newRequest:feed.meta] xmlBlock:nil feedBlock:^(RSParsedFeed *rss, NSError *error, NSHTTPURLResponse *response) {
|
||||||
|
if (!feed.isDeleted) {
|
||||||
if (error) {
|
if (error) {
|
||||||
if (alert) [NSApp presentError:error];
|
if (alert) {
|
||||||
|
NSAlert *alertPopup = [NSAlert alertWithError:error];
|
||||||
|
alertPopup.informativeText = [NSString stringWithFormat:@"Error loading source: %@", response.URL.absoluteString];
|
||||||
|
[alertPopup runModal];
|
||||||
|
}
|
||||||
[feed.meta setErrorAndPostponeSchedule];
|
[feed.meta setErrorAndPostponeSchedule];
|
||||||
[failed addObject:feed];
|
[failed addObject:feed];
|
||||||
} else {
|
} else {
|
||||||
@@ -294,6 +306,7 @@ static BOOL _nextUpdateIsForced = NO;
|
|||||||
if (rss) [feed updateWithRSS:rss postUnreadCountChange:YES];
|
if (rss) [feed updateWithRSS:rss postUnreadCountChange:YES];
|
||||||
[successful addObject:feed]; // will be added even if statusCode == 304 (rss == nil)
|
[successful addObject:feed]; // will be added even if statusCode == 304 (rss == nil)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
dispatch_group_leave(group);
|
dispatch_group_leave(group);
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
@@ -421,6 +434,8 @@ static BOOL _nextUpdateIsForced = NO;
|
|||||||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
||||||
NSURL *favURL = [[self hostURL:urlStr] URLByAppendingPathComponent:@"favicon.ico"];
|
NSURL *favURL = [[self hostURL:urlStr] URLByAppendingPathComponent:@"favicon.ico"];
|
||||||
// TODO: fix anonymous session. initWithContentsOfURL: will set cookie in ~/Library/Cookies/
|
// TODO: fix anonymous session. initWithContentsOfURL: will set cookie in ~/Library/Cookies/
|
||||||
|
// TODO: check ~/Library/Caches/de.relikd.baRSS/fsCachedData/
|
||||||
|
// TODO: fix missing favicon by parsing html
|
||||||
NSImage *img = [[NSImage alloc] initWithContentsOfURL:favURL];
|
NSImage *img = [[NSImage alloc] initWithContentsOfURL:favURL];
|
||||||
if (!img || ![img isValid])
|
if (!img || ![img isValid])
|
||||||
img = nil;
|
img = nil;
|
||||||
|
|||||||
38
baRSS/Helper/Statistics.h
Normal file
38
baRSS/Helper/Statistics.h
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
//
|
||||||
|
// The MIT License (MIT)
|
||||||
|
// Copyright (c) 2019 Oleg Geier
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
// this software and associated documentation files (the "Software"), to deal in
|
||||||
|
// the Software without restriction, including without limitation the rights to
|
||||||
|
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||||
|
// of the Software, and to permit persons to whom the Software is furnished to do
|
||||||
|
// so, subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
#import <Cocoa/Cocoa.h>
|
||||||
|
|
||||||
|
@protocol RefreshIntervalButtonDelegate <NSObject>
|
||||||
|
@required
|
||||||
|
/**
|
||||||
|
The interval-unit combination is stored as follows:
|
||||||
|
:: @c sender.tag @c >> @c 3 (Refresh Interval)
|
||||||
|
:: @c sender.tag @c & @c 0x7 (Refresh Unit, where 0: seconds and 4: weeks)
|
||||||
|
*/
|
||||||
|
- (void)refreshIntervalButtonClicked:(NSButton*)sender;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface Statistics : NSObject
|
||||||
|
+ (NSDictionary*)refreshInterval:(NSArray<NSDate*> *)list;
|
||||||
|
+ (NSView*)viewForRefreshInterval:(NSDictionary*)info articleCount:(NSUInteger)count callback:(nullable id<RefreshIntervalButtonDelegate>)callback;
|
||||||
|
@end
|
||||||
222
baRSS/Helper/Statistics.m
Normal file
222
baRSS/Helper/Statistics.m
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
//
|
||||||
|
// The MIT License (MIT)
|
||||||
|
// Copyright (c) 2019 Oleg Geier
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
// this software and associated documentation files (the "Software"), to deal in
|
||||||
|
// the Software without restriction, including without limitation the rights to
|
||||||
|
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||||
|
// of the Software, and to permit persons to whom the Software is furnished to do
|
||||||
|
// so, subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
#import "Statistics.h"
|
||||||
|
|
||||||
|
@implementation Statistics
|
||||||
|
|
||||||
|
#pragma mark - Generate Refresh Interval Statistics
|
||||||
|
|
||||||
|
/**
|
||||||
|
@return @c nil if list contains less than 2 entries. Otherwise: @{min, max, avg, median, earliest, latest}
|
||||||
|
*/
|
||||||
|
+ (NSDictionary*)refreshInterval:(NSArray<NSDate*> *)list {
|
||||||
|
if (!list || list.count == 0)
|
||||||
|
return nil;
|
||||||
|
|
||||||
|
NSDate *earliest = [NSDate distantFuture];
|
||||||
|
NSDate *latest = [NSDate distantPast];
|
||||||
|
NSDate *prev = nil;
|
||||||
|
NSMutableArray<NSNumber*> *differences = [NSMutableArray array];
|
||||||
|
for (NSDate *d in list) {
|
||||||
|
if (![d isKindOfClass:[NSDate class]]) // because valueForKeyPath: can return NSNull
|
||||||
|
continue;
|
||||||
|
earliest = [d earlierDate:earliest];
|
||||||
|
latest = [d laterDate:latest];
|
||||||
|
if (prev) {
|
||||||
|
int dif = abs((int)[d timeIntervalSinceDate:prev]);
|
||||||
|
[differences addObject:[NSNumber numberWithInt:dif]];
|
||||||
|
}
|
||||||
|
prev = d;
|
||||||
|
}
|
||||||
|
if (differences.count == 0)
|
||||||
|
return nil;
|
||||||
|
|
||||||
|
[differences sortUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"intValue" ascending:YES]]];
|
||||||
|
|
||||||
|
NSUInteger i = differences.count;
|
||||||
|
NSUInteger mid = (i/2);
|
||||||
|
unsigned int med = differences[mid].unsignedIntValue;
|
||||||
|
if (i > 1 && (i % 1) == 0) { // even feed count, use median of two values
|
||||||
|
med = (med + differences[mid+1].unsignedIntValue) / 2;
|
||||||
|
}
|
||||||
|
return @{@"min" : [self stringForInterval:differences.firstObject.unsignedIntValue],
|
||||||
|
@"max" : [self stringForInterval:differences.lastObject.unsignedIntValue],
|
||||||
|
@"avg" : [self stringForInterval:[(NSNumber*)[differences valueForKeyPath:@"@avg.self"] unsignedIntValue]],
|
||||||
|
@"median" : [self stringForInterval:med],
|
||||||
|
@"earliest" : earliest,
|
||||||
|
@"latest" : latest };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print @c 1.1f float string with single char unit: e.g., 3.3m, 1.7h
|
||||||
|
+ (NSString*)stringForInterval:(unsigned int)val {
|
||||||
|
float i;
|
||||||
|
NSUInteger u = [self findAppropriateTimeUnit:val interval:&i];
|
||||||
|
return [NSString stringWithFormat:@"%1.1f%c", i, [@"smhdw" characterAtIndex:u]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @return Unit as int @c (0-4) (0: seconds - 4: weeks). Sets division result @c intv.
|
||||||
|
+ (NSUInteger)findAppropriateTimeUnit:(unsigned int)val interval:(float*)intv {
|
||||||
|
if (val > 604800) {*intv = (val / 604800.f); return 4;} // weeks
|
||||||
|
if (val > 86400) {*intv = (val / 86400.f); return 3;} // days
|
||||||
|
if (val > 3600) {*intv = (val / 3600.f); return 2;} // hours
|
||||||
|
if (val > 60) {*intv = (val / 60.f); return 1;} // minutes
|
||||||
|
*intv = (val / 1.f);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @return Single integer value that combines refresh interval and refresh unit. To be used as @c NSButton.tag
|
||||||
|
+ (NSInteger)buttonTagFromRefreshString:(NSString*)str {
|
||||||
|
NSInteger refresh = (NSInteger)roundf([str floatValue]) << 3;
|
||||||
|
switch ([str characterAtIndex:(str.length - 1)]) {
|
||||||
|
case 's': return 0 | refresh;
|
||||||
|
case 'm': return 1 | refresh;
|
||||||
|
case 'h': return 2 | refresh;
|
||||||
|
case 'd': return 3 | refresh;
|
||||||
|
case 'w': return 4 | refresh;
|
||||||
|
}
|
||||||
|
return 0; // error, should never happen though
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - Feed Statistics UI
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
Generate UI with buttons for min, max, avg and median. Also show number of articles and latest article date.
|
||||||
|
|
||||||
|
@param info The dictionary generated with @c -refreshInterval:
|
||||||
|
@param count Article count.
|
||||||
|
@param callback If set, @c sender will be called with @c -refreshIntervalButtonClicked:.
|
||||||
|
If not disable button border and display as bold inline text.
|
||||||
|
@return Centered view without autoresizing.
|
||||||
|
*/
|
||||||
|
+ (NSView*)viewForRefreshInterval:(NSDictionary*)info articleCount:(NSUInteger)count callback:(nullable id<RefreshIntervalButtonDelegate>)callback {
|
||||||
|
NSString *lbl = [NSString stringWithFormat:NSLocalizedString(@"%lu articles.", nil), count];
|
||||||
|
if (!info || info.count == 0)
|
||||||
|
return [self grayLabel:lbl];
|
||||||
|
|
||||||
|
// Subview with 4 button (min, max, avg, median)
|
||||||
|
NSView *buttonsView = [[NSView alloc] init];
|
||||||
|
NSPoint origin = NSZeroPoint;
|
||||||
|
for (NSString *str in @[@"min", @"max", @"avg", @"median"]) {
|
||||||
|
NSString *title = [str stringByAppendingString:@":"];
|
||||||
|
NSString *value = [info valueForKey:str];
|
||||||
|
NSView *v = [self viewWithLabel:title andRefreshButton:value callback:callback];
|
||||||
|
[v setFrameOrigin:origin];
|
||||||
|
[buttonsView addSubview:v];
|
||||||
|
origin.x += NSWidth(v.frame);
|
||||||
|
}
|
||||||
|
[buttonsView setFrameSize:NSMakeSize(origin.x, NSHeight(buttonsView.subviews.firstObject.frame))];
|
||||||
|
|
||||||
|
// Subview with article count and latest article date
|
||||||
|
NSDate *lastUpdate = [info valueForKey:@"latest"];
|
||||||
|
NSString *mod = [NSDateFormatter localizedStringFromDate:lastUpdate dateStyle:NSDateFormatterMediumStyle timeStyle:NSDateFormatterShortStyle];
|
||||||
|
NSTextField *dateView = [self grayLabel:[lbl stringByAppendingFormat:@" (latest: %@)", mod]];
|
||||||
|
|
||||||
|
// Feed wasn't updated in a while ...
|
||||||
|
if ([lastUpdate timeIntervalSinceNow] < (-360 * 24 * 60 * 60)) {
|
||||||
|
NSMutableAttributedString *as = dateView.attributedStringValue.mutableCopy;
|
||||||
|
[as addAttribute:NSForegroundColorAttributeName value:[NSColor systemRedColor] range:NSMakeRange(lbl.length, as.length - lbl.length)];
|
||||||
|
[dateView setAttributedStringValue:as];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate offset and align both horizontally centered
|
||||||
|
CGFloat maxWidth = NSWidth(buttonsView.frame);
|
||||||
|
if (maxWidth < NSWidth(dateView.frame))
|
||||||
|
maxWidth = NSWidth(dateView.frame);
|
||||||
|
[buttonsView setFrameOrigin:NSMakePoint(0.5f*(maxWidth - NSWidth(buttonsView.frame)), 0)];
|
||||||
|
[dateView setFrameOrigin:NSMakePoint(0.5f*(maxWidth - NSWidth(dateView.frame)), NSHeight(buttonsView.frame))];
|
||||||
|
|
||||||
|
// Dump both into single parent view and make that view centered during resize
|
||||||
|
NSView *parent = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, maxWidth, NSMaxY(dateView.frame))];
|
||||||
|
parent.autoresizingMask = NSViewMinXMargin | NSViewMaxXMargin;// | NSViewMinYMargin | NSViewMaxYMargin;
|
||||||
|
parent.autoresizesSubviews = NO;
|
||||||
|
// parent.layer = [CALayer layer];
|
||||||
|
// parent.layer.backgroundColor = [NSColor systemYellowColor].CGColor;
|
||||||
|
[parent addSubview:dateView];
|
||||||
|
[parent addSubview:buttonsView];
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Create view with duration button, e.g., '3.4h' and label infornt of it.
|
||||||
|
*/
|
||||||
|
+ (NSView*)viewWithLabel:(NSString*)title andRefreshButton:(NSString*)value callback:(nullable id<RefreshIntervalButtonDelegate>)callback {
|
||||||
|
static const int buttonPadding = 5;
|
||||||
|
if (!value || value.length == 0)
|
||||||
|
return nil;
|
||||||
|
|
||||||
|
NSButton *button = [self grayInlineButton:value];
|
||||||
|
if (callback) {
|
||||||
|
button.target = callback;
|
||||||
|
button.action = @selector(refreshIntervalButtonClicked:);
|
||||||
|
} else {
|
||||||
|
button.bordered = NO;
|
||||||
|
button.enabled = NO;
|
||||||
|
}
|
||||||
|
NSTextField *label;
|
||||||
|
if (title && title.length > 0) {
|
||||||
|
label = [self grayLabel:title];
|
||||||
|
[label setFrameOrigin:NSMakePoint(0, button.alignmentRectInsets.bottom + 0.5f*(NSHeight(button.frame) - NSHeight(label.frame)))];
|
||||||
|
}
|
||||||
|
[button setFrameOrigin:NSMakePoint(NSWidth(label.frame), 0)];
|
||||||
|
|
||||||
|
CGFloat maxHeight = NSHeight(button.frame);
|
||||||
|
if (maxHeight < NSHeight(label.frame))
|
||||||
|
maxHeight = NSHeight(label.frame);
|
||||||
|
|
||||||
|
NSView *parent = [[NSView alloc] initWithFrame: NSMakeRect(0, 0, NSMaxX(button.frame) + buttonPadding, maxHeight + buttonPadding)];
|
||||||
|
[parent addSubview:label];
|
||||||
|
[parent addSubview:button];
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
@return Rounded, gray inline button with tag equal to refresh interval.
|
||||||
|
*/
|
||||||
|
+ (NSButton*)grayInlineButton:(NSString*)text {
|
||||||
|
NSButton *button = [NSButton buttonWithTitle:text target:nil action:nil];
|
||||||
|
button.font = [NSFont monospacedDigitSystemFontOfSize: NSFont.labelFontSize weight:NSFontWeightBold];
|
||||||
|
button.bezelStyle = NSBezelStyleInline;
|
||||||
|
button.controlSize = NSControlSizeSmall;
|
||||||
|
button.tag = [self buttonTagFromRefreshString:text];
|
||||||
|
[button sizeToFit];
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
@return Simple Label with smaller gray text, non-editable.
|
||||||
|
*/
|
||||||
|
+ (NSTextField*)grayLabel:(NSString*)text {
|
||||||
|
NSTextField *label = [NSTextField textFieldWithString:text];
|
||||||
|
label.font = [NSFont monospacedDigitSystemFontOfSize: NSFont.labelFontSize weight:NSFontWeightRegular];
|
||||||
|
label.textColor = [NSColor systemGrayColor];
|
||||||
|
label.drawsBackground = NO;
|
||||||
|
label.selectable = NO;
|
||||||
|
label.editable = NO;
|
||||||
|
label.bezeled = NO;
|
||||||
|
[label sizeToFit];
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -26,6 +26,8 @@
|
|||||||
#import "Feed+Ext.h"
|
#import "Feed+Ext.h"
|
||||||
#import "FeedMeta+Ext.h"
|
#import "FeedMeta+Ext.h"
|
||||||
#import "FeedGroup+Ext.h"
|
#import "FeedGroup+Ext.h"
|
||||||
|
#import "Statistics.h"
|
||||||
|
#import <QuartzCore/QuartzCore.h>
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - ModalEditDialog -
|
#pragma mark - ModalEditDialog -
|
||||||
@@ -46,7 +48,7 @@
|
|||||||
/// @return New @c ModalSheet with its subclass @c .view property as dialog content.
|
/// @return New @c ModalSheet with its subclass @c .view property as dialog content.
|
||||||
- (ModalSheet *)getModalSheet {
|
- (ModalSheet *)getModalSheet {
|
||||||
if (!self.modalSheet)
|
if (!self.modalSheet)
|
||||||
self.modalSheet = [ModalSheet modalWithView:self.view];
|
self.modalSheet = [[ModalSheet alloc] initWithView:self.view];
|
||||||
return self.modalSheet;
|
return self.modalSheet;
|
||||||
}
|
}
|
||||||
/// This method should be overridden by subclasses. Used to save changes to persistent store.
|
/// This method should be overridden by subclasses. Used to save changes to persistent store.
|
||||||
@@ -60,7 +62,7 @@
|
|||||||
#pragma mark - ModalFeedEdit -
|
#pragma mark - ModalFeedEdit -
|
||||||
|
|
||||||
|
|
||||||
@interface ModalFeedEdit()
|
@interface ModalFeedEdit() <RefreshIntervalButtonDelegate>
|
||||||
@property (weak) IBOutlet NSTextField *url;
|
@property (weak) IBOutlet NSTextField *url;
|
||||||
@property (weak) IBOutlet NSTextField *name;
|
@property (weak) IBOutlet NSTextField *name;
|
||||||
@property (weak) IBOutlet NSTextField *refreshNum;
|
@property (weak) IBOutlet NSTextField *refreshNum;
|
||||||
@@ -69,6 +71,7 @@
|
|||||||
@property (weak) IBOutlet NSProgressIndicator *spinnerName;
|
@property (weak) IBOutlet NSProgressIndicator *spinnerName;
|
||||||
@property (weak) IBOutlet NSButton *warningIndicator;
|
@property (weak) IBOutlet NSButton *warningIndicator;
|
||||||
@property (weak) IBOutlet NSPopover *warningPopover;
|
@property (weak) IBOutlet NSPopover *warningPopover;
|
||||||
|
@property (strong) NSView *statisticsView;
|
||||||
|
|
||||||
@property (copy) NSString *previousURL; // check if changed and avoid multiple download
|
@property (copy) NSString *previousURL; // check if changed and avoid multiple download
|
||||||
@property (copy) NSString *httpDate;
|
@property (copy) NSString *httpDate;
|
||||||
@@ -105,6 +108,7 @@
|
|||||||
unit = self.refreshUnit.numberOfItems - 1;
|
unit = self.refreshUnit.numberOfItems - 1;
|
||||||
[self.refreshUnit selectItemAtIndex:unit];
|
[self.refreshUnit selectItemAtIndex:unit];
|
||||||
self.warningIndicator.image = [fg.feed iconImage16];
|
self.warningIndicator.image = [fg.feed iconImage16];
|
||||||
|
[self statsForCoreDataObject];
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - Edit Feed Data
|
#pragma mark - Edit Feed Data
|
||||||
@@ -189,6 +193,7 @@
|
|||||||
NSPoint belowURL = NSMakePoint(0,self.url.frame.size.height);
|
NSPoint belowURL = NSMakePoint(0,self.url.frame.size.height);
|
||||||
if ([menu popUpMenuPositioningItem:nil atLocation:belowURL inView:self.url]) {
|
if ([menu popUpMenuPositioningItem:nil atLocation:belowURL inView:self.url]) {
|
||||||
NSInteger idx = [menu indexOfItem:menu.highlightedItem];
|
NSInteger idx = [menu indexOfItem:menu.highlightedItem];
|
||||||
|
if (idx < 0) idx = 0; // User hit enter without selection. Assume first item, because PopUpMenu did return YES!
|
||||||
return [list objectAtIndex:(NSUInteger)idx].link;
|
return [list objectAtIndex:(NSUInteger)idx].link;
|
||||||
}
|
}
|
||||||
return nil; // user selection canceled
|
return nil; // user selection canceled
|
||||||
@@ -214,6 +219,8 @@
|
|||||||
if (parsedTitle.length > 0 && [self.name.stringValue isEqualToString:@""]) {
|
if (parsedTitle.length > 0 && [self.name.stringValue isEqualToString:@""]) {
|
||||||
self.name.stringValue = parsedTitle; // no damage to replace an empty string
|
self.name.stringValue = parsedTitle; // no damage to replace an empty string
|
||||||
}
|
}
|
||||||
|
// TODO: user preference to automatically select refresh interval (selection: None,min,max,avg,median)
|
||||||
|
[self statsForDownloadObject];
|
||||||
// 4. Continue with favicon download (or finish with error)
|
// 4. Continue with favicon download (or finish with error)
|
||||||
if (self.feedError) {
|
if (self.feedError) {
|
||||||
[self finishDownloadWithFavicon:[NSImage imageNamed:NSImageNameCaution]];
|
[self finishDownloadWithFavicon:[NSImage imageNamed:NSImageNameCaution]];
|
||||||
@@ -243,6 +250,70 @@
|
|||||||
[self.modalSheet setDoneEnabled:YES];
|
[self.modalSheet setDoneEnabled:YES];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#pragma mark - Feed Statistics
|
||||||
|
|
||||||
|
/// Perform statistics on newly downloaded feed item
|
||||||
|
- (void)statsForDownloadObject {
|
||||||
|
NSMutableArray<NSDate*> *arr = [NSMutableArray arrayWithCapacity:self.feedResult.articles.count];
|
||||||
|
for (RSParsedArticle *a in self.feedResult.articles) {
|
||||||
|
NSDate *d = a.datePublished;
|
||||||
|
if (!d) d = a.dateModified;
|
||||||
|
if (!d) continue;
|
||||||
|
[arr addObject:d];
|
||||||
|
}
|
||||||
|
[self appendViewWithFeedStatistics:arr count:self.feedResult.articles.count];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform statistics on stored core data object
|
||||||
|
- (void)statsForCoreDataObject {
|
||||||
|
NSArray<FeedArticle*> *articles = [self.feedGroup.feed sortedArticles];
|
||||||
|
[self appendViewWithFeedStatistics:[articles valueForKeyPath:@"published"] count:articles.count];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate statistics UI with buttons to quickly select refresh unit and duration.
|
||||||
|
- (void)appendViewWithFeedStatistics:(NSArray*)dates count:(NSUInteger)count {
|
||||||
|
static const CGFloat statsPadding = 15.f;
|
||||||
|
CGFloat prevHeight = 0.f;
|
||||||
|
if (self.statisticsView != nil) {
|
||||||
|
prevHeight = self.statisticsView.frame.size.height + statsPadding;
|
||||||
|
[self.statisticsView removeFromSuperview];
|
||||||
|
self.statisticsView = nil;
|
||||||
|
}
|
||||||
|
NSDictionary *stats = [Statistics refreshInterval:dates];
|
||||||
|
NSView *v = [Statistics viewForRefreshInterval:stats articleCount:count callback:self];
|
||||||
|
[[self getModalSheet] extendContentViewBy:v.frame.size.height + statsPadding - prevHeight];
|
||||||
|
[v setFrameOrigin:NSMakePoint(0.5f*(NSWidth(self.view.frame) - NSWidth(v.frame)), 0)];
|
||||||
|
[self.view addSubview:v];
|
||||||
|
self.statisticsView = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Callback method for @c Statistics @c +viewForRefreshInterval:articleCount:callback:
|
||||||
|
- (void)refreshIntervalButtonClicked:(NSButton *)sender {
|
||||||
|
NSInteger num = (sender.tag >> 3);
|
||||||
|
NSInteger unit = (sender.tag & 0x7);
|
||||||
|
if (self.refreshNum.integerValue != num) {
|
||||||
|
[self animateControlAttention:self.refreshNum];
|
||||||
|
self.refreshNum.integerValue = num;
|
||||||
|
}
|
||||||
|
if (self.refreshUnit.indexOfSelectedItem != unit) {
|
||||||
|
[self animateControlAttention:self.refreshUnit];
|
||||||
|
[self.refreshUnit selectItemAtIndex:unit];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper method to animate @c NSControl to draw user attention. View will be scalled up in a fraction of a second.
|
||||||
|
- (void)animateControlAttention:(NSView*)control {
|
||||||
|
CABasicAnimation *scale = [CABasicAnimation animationWithKeyPath:@"transform"];
|
||||||
|
CATransform3D tr = CATransform3DIdentity;
|
||||||
|
tr = CATransform3DTranslate(tr, NSMidX(control.bounds), NSMidY(control.bounds), 0);
|
||||||
|
tr = CATransform3DScale(tr, 1.1, 1.1, 1);
|
||||||
|
tr = CATransform3DTranslate(tr, -NSMidX(control.bounds), -NSMidY(control.bounds), 0);
|
||||||
|
scale.toValue = [NSValue valueWithCATransform3D:tr];
|
||||||
|
scale.duration = 0.15f;
|
||||||
|
scale.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseIn];
|
||||||
|
[control.layer addAnimation:scale forKey:scale.keyPath];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - NSTextField Delegate
|
#pragma mark - NSTextField Delegate
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
|
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
|
||||||
<customView id="i0K-k8-GMU" userLabel="View">
|
<customView id="i0K-k8-GMU" userLabel="View">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="320" height="79"/>
|
<rect key="frame" x="0.0" y="0.0" width="320" height="79"/>
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="MOX-a1-Yda" userLabel="URL Label">
|
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="MOX-a1-Yda" userLabel="URL Label">
|
||||||
<rect key="frame" x="-2" y="60" width="103" height="17"/>
|
<rect key="frame" x="-2" y="60" width="103" height="17"/>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
/// Display Save File Panel to select export destination. All feeds from core data will be exported.
|
/// Display Save File Panel to select export destination. All feeds from core data will be exported.
|
||||||
+ (void)showExportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc {
|
+ (void)showExportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc {
|
||||||
NSSavePanel *sp = [NSSavePanel savePanel];
|
NSSavePanel *sp = [NSSavePanel savePanel];
|
||||||
sp.nameFieldStringValue = [NSString stringWithFormat:@"baRSS feeds %@", [self currentDayAsString]];
|
sp.nameFieldStringValue = [NSString stringWithFormat:@"baRSS feeds %@", [self currentDayAsStringISO8601:NO]];
|
||||||
sp.allowedFileTypes = @[@"opml"];
|
sp.allowedFileTypes = @[@"opml"];
|
||||||
sp.allowsOtherFileTypes = YES;
|
sp.allowsOtherFileTypes = YES;
|
||||||
NSView *radioView = [self radioGroupCreate:@[NSLocalizedString(@"Hierarchical", nil),
|
NSView *radioView = [self radioGroupCreate:@[NSLocalizedString(@"Hierarchical", nil),
|
||||||
@@ -68,6 +68,7 @@
|
|||||||
/// Handle import dialog and perform web requests (feed data & icon). Creates a single undo group.
|
/// Handle import dialog and perform web requests (feed data & icon). Creates a single undo group.
|
||||||
+ (void)showImportDialog:(NSWindow*)window withTreeController:(NSTreeController*)tree {
|
+ (void)showImportDialog:(NSWindow*)window withTreeController:(NSTreeController*)tree {
|
||||||
NSManagedObjectContext *moc = tree.managedObjectContext;
|
NSManagedObjectContext *moc = tree.managedObjectContext;
|
||||||
|
//[moc refreshAllObjects];
|
||||||
[moc.undoManager beginUndoGrouping];
|
[moc.undoManager beginUndoGrouping];
|
||||||
[self showImportDialog:window withContext:moc success:^(NSArray<Feed *> *added) {
|
[self showImportDialog:window withContext:moc success:^(NSArray<Feed *> *added) {
|
||||||
[StoreCoordinator saveContext:moc andParent:YES];
|
[StoreCoordinator saveContext:moc andParent:YES];
|
||||||
@@ -204,7 +205,9 @@
|
|||||||
@return Save this string to file.
|
@return Save this string to file.
|
||||||
*/
|
*/
|
||||||
+ (NSString*)exportFeedsHierarchical:(BOOL)flag inContext:(NSManagedObjectContext*)moc {
|
+ (NSString*)exportFeedsHierarchical:(BOOL)flag inContext:(NSManagedObjectContext*)moc {
|
||||||
NSDictionary *info = @{@"dateCreated" : [NSDate date], @"ownerName" : @"baRSS", OPMLTitleKey : @"baRSS feeds"};
|
NSDictionary *info = @{OPMLTitleKey : @"baRSS feeds",
|
||||||
|
@"ownerName" : @"baRSS",
|
||||||
|
@"dateCreated" : [self currentDayAsStringISO8601:YES]};
|
||||||
RSOPMLItem *doc = [RSOPMLItem itemWithAttributes:info];
|
RSOPMLItem *doc = [RSOPMLItem itemWithAttributes:info];
|
||||||
@autoreleasepool {
|
@autoreleasepool {
|
||||||
NSArray<FeedGroup*> *arr = [StoreCoordinator sortedListOfRootObjectsInContext:moc];
|
NSArray<FeedGroup*> *arr = [StoreCoordinator sortedListOfRootObjectsInContext:moc];
|
||||||
@@ -246,10 +249,13 @@
|
|||||||
#pragma mark - Helper
|
#pragma mark - Helper
|
||||||
|
|
||||||
|
|
||||||
/// @return Date formatted as @c yyyy-MM-dd
|
/// @param flag If @c YES use long internet format for opml file. If @c NO use short format as filename.
|
||||||
+ (NSString*)currentDayAsString {
|
+ (NSString*)currentDayAsStringISO8601:(BOOL)flag {
|
||||||
NSDateComponents *now = [[NSCalendar currentCalendar] components:NSCalendarUnitDay | NSCalendarUnitMonth | NSCalendarUnitYear fromDate:[NSDate date]];
|
if (flag)
|
||||||
return [NSString stringWithFormat:@"%04ld-%02ld-%02ld", now.year, now.month, now.day];
|
return [[[NSISO8601DateFormatter alloc] init] stringFromDate:[NSDate date]];
|
||||||
|
// NSDateComponents *now = [[NSCalendar currentCalendar] components:NSCalendarUnitDay | NSCalendarUnitMonth | NSCalendarUnitYear fromDate:[NSDate date]];
|
||||||
|
// return [NSString stringWithFormat:@"%04ld-%02ld-%02ld", now.year, now.month, now.day];
|
||||||
|
return [NSDateFormatter localizedStringFromDate:[NSDate date] dateStyle:NSDateFormatterShortStyle timeStyle:NSDateFormatterNoStyle];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Count items where @c xmlURL key is set.
|
/// Count items where @c xmlURL key is set.
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
|
|
||||||
@implementation SettingsFeeds
|
@implementation SettingsFeeds
|
||||||
|
|
||||||
|
// TODO: drag-n-drop feeds to opml file?
|
||||||
// Declare a string constant for the drag type - to be used when writing and retrieving pasteboard data...
|
// Declare a string constant for the drag type - to be used when writing and retrieving pasteboard data...
|
||||||
static NSString *dragNodeType = @"baRSS-feed-drag";
|
static NSString *dragNodeType = @"baRSS-feed-drag";
|
||||||
|
|
||||||
@@ -52,37 +53,80 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
|
|
||||||
self.dataStore.managedObjectContext = [StoreCoordinator createChildContext];
|
self.dataStore.managedObjectContext = [StoreCoordinator createChildContext];
|
||||||
self.dataStore.managedObjectContext.undoManager = self.undoManager;
|
self.dataStore.managedObjectContext.undoManager = self.undoManager;
|
||||||
|
self.dataStore.managedObjectContext.automaticallyMergesChangesFromParent = NO;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Refresh current context from parent context and start new undo grouping.
|
||||||
|
@note Should be balanced with @c endCoreDataChangeUndoChanges:
|
||||||
|
*/
|
||||||
|
- (void)beginCoreDataChange {
|
||||||
|
// Does seem to create problems with undo stack if refreshing from parent context
|
||||||
|
//[self.dataStore.managedObjectContext refreshAllObjects];
|
||||||
|
[self.undoManager beginUndoGrouping];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
End undo grouping and save changes to persistent store. Or undo group if no changes occured.
|
||||||
|
@note Should be balanced with @c beginCoreDataChange
|
||||||
|
|
||||||
|
@param flag If @c YES force @c NSUndoManager to undo the changes immediatelly.
|
||||||
|
@return Returns @c YES if context was saved.
|
||||||
|
*/
|
||||||
|
- (BOOL)endCoreDataChangeShouldUndo:(BOOL)flag {
|
||||||
|
[self.undoManager endUndoGrouping];
|
||||||
|
if (!flag && self.dataStore.managedObjectContext.hasChanges) {
|
||||||
|
[StoreCoordinator saveContext:self.dataStore.managedObjectContext andParent:YES];
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
[self.undoManager disableUndoRegistration];
|
||||||
|
[self.undoManager undoNestedGroup];
|
||||||
|
[self.undoManager enableUndoRegistration];
|
||||||
|
return NO;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
After the user did undo or redo we can't ensure integrity without doing some additional work.
|
||||||
|
*/
|
||||||
|
- (void)saveWithUnpredictableChange {
|
||||||
|
NSSet<Feed*> *arr = [self.dataStore.managedObjectContext.insertedObjects
|
||||||
|
filteredSetUsingPredicate:[NSPredicate predicateWithFormat:@"class == %@", [Feed class]]];
|
||||||
|
[StoreCoordinator saveContext:self.dataStore.managedObjectContext andParent:YES];
|
||||||
|
[StoreCoordinator restoreFeedCountsAndIndexPaths:[arr valueForKeyPath:@"objectID"]]; // main context will not create undo group
|
||||||
|
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
|
||||||
|
[self.dataStore rearrangeObjects]; // update ordering
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - UI Button Interaction
|
#pragma mark - UI Button Interaction
|
||||||
|
|
||||||
|
|
||||||
|
/// Add feed button.
|
||||||
- (IBAction)addFeed:(id)sender {
|
- (IBAction)addFeed:(id)sender {
|
||||||
[self showModalForFeedGroup:nil isGroupEdit:NO];
|
[self showModalForFeedGroup:nil isGroupEdit:NO];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add group button.
|
||||||
- (IBAction)addGroup:(id)sender {
|
- (IBAction)addGroup:(id)sender {
|
||||||
[self showModalForFeedGroup:nil isGroupEdit:YES];
|
[self showModalForFeedGroup:nil isGroupEdit:YES];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add separator button.
|
||||||
- (IBAction)addSeparator:(id)sender {
|
- (IBAction)addSeparator:(id)sender {
|
||||||
[self.undoManager beginUndoGrouping];
|
[self beginCoreDataChange];
|
||||||
[self insertFeedGroupAtSelection:SEPARATOR].name = @"---";
|
[self insertFeedGroupAtSelection:SEPARATOR].name = @"---";
|
||||||
[self.undoManager endUndoGrouping];
|
[self endCoreDataChangeShouldUndo:NO];
|
||||||
[self saveChanges];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove user selected item from persistent store.
|
/// Remove feed button. User has selected one or more item in outline view.
|
||||||
- (IBAction)remove:(id)sender {
|
- (IBAction)remove:(id)sender {
|
||||||
[self.undoManager beginUndoGrouping];
|
[self beginCoreDataChange];
|
||||||
NSArray<NSTreeNode*> *parentNodes = [self.dataStore.selectedNodes valueForKeyPath:@"parentNode"];
|
NSArray<NSTreeNode*> *parentNodes = [self.dataStore.selectedNodes valueForKeyPath:@"parentNode"];
|
||||||
[self.dataStore remove:sender];
|
[self.dataStore remove:sender];
|
||||||
for (NSTreeNode *parent in parentNodes) {
|
for (NSTreeNode *parent in parentNodes) {
|
||||||
[self restoreOrderingAndIndexPathStr:parent];
|
[self restoreOrderingAndIndexPathStr:parent];
|
||||||
}
|
}
|
||||||
[self.undoManager endUndoGrouping];
|
[self endCoreDataChangeShouldUndo:NO];
|
||||||
[self saveChanges];
|
|
||||||
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
|
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +138,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
[self showModalForFeedGroup:fg isGroupEdit:YES]; // yes will be overwritten anyway
|
[self showModalForFeedGroup:fg isGroupEdit:YES]; // yes will be overwritten anyway
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Share menu button. Currently only import & export feeds as OPML.
|
||||||
- (IBAction)shareMenu:(NSButton*)sender {
|
- (IBAction)shareMenu:(NSButton*)sender {
|
||||||
if (!sender.menu) {
|
if (!sender.menu) {
|
||||||
sender.menu = [[NSMenu alloc] initWithTitle:NSLocalizedString(@"Import / Export menu", nil)];
|
sender.menu = [[NSMenu alloc] initWithTitle:NSLocalizedString(@"Import / Export menu", nil)];
|
||||||
@@ -116,11 +161,6 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
#pragma mark - Insert & Edit Feed Items / Modal Dialog
|
#pragma mark - Insert & Edit Feed Items / Modal Dialog
|
||||||
|
|
||||||
|
|
||||||
/// Save core data changes of current object context to persistent store
|
|
||||||
- (void)saveChanges {
|
|
||||||
[StoreCoordinator saveContext:self.dataStore.managedObjectContext andParent:YES];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Open a new modal window to edit the selected @c FeedGroup.
|
Open a new modal window to edit the selected @c FeedGroup.
|
||||||
@note isGroupEdit @c flag will be overwritten if @c FeedGroup parameter is not @c nil.
|
@note isGroupEdit @c flag will be overwritten if @c FeedGroup parameter is not @c nil.
|
||||||
@@ -130,7 +170,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
*/
|
*/
|
||||||
- (void)showModalForFeedGroup:(FeedGroup*)fg isGroupEdit:(BOOL)flag {
|
- (void)showModalForFeedGroup:(FeedGroup*)fg isGroupEdit:(BOOL)flag {
|
||||||
if (fg.type == SEPARATOR) return;
|
if (fg.type == SEPARATOR) return;
|
||||||
[self.undoManager beginUndoGrouping];
|
[self beginCoreDataChange];
|
||||||
if (!fg || ![fg isKindOfClass:[FeedGroup class]]) {
|
if (!fg || ![fg isKindOfClass:[FeedGroup class]]) {
|
||||||
fg = [self insertFeedGroupAtSelection:(flag ? GROUP : FEED)];
|
fg = [self insertFeedGroupAtSelection:(flag ? GROUP : FEED)];
|
||||||
}
|
}
|
||||||
@@ -140,19 +180,9 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
[self.view.window beginSheet:[editDialog getModalSheet] completionHandler:^(NSModalResponse returnCode) {
|
[self.view.window beginSheet:[editDialog getModalSheet] completionHandler:^(NSModalResponse returnCode) {
|
||||||
if (returnCode == NSModalResponseOK) {
|
if (returnCode == NSModalResponseOK) {
|
||||||
[editDialog applyChangesToCoreDataObject];
|
[editDialog applyChangesToCoreDataObject];
|
||||||
[self.undoManager endUndoGrouping];
|
|
||||||
} else {
|
|
||||||
[self.undoManager endUndoGrouping];
|
|
||||||
[self.dataStore.managedObjectContext rollback];
|
|
||||||
}
|
}
|
||||||
BOOL hasChanges = [self.dataStore.managedObjectContext hasChanges];
|
if ([self endCoreDataChangeShouldUndo:(returnCode != NSModalResponseOK)]) {
|
||||||
if (hasChanges) {
|
|
||||||
[self saveChanges];
|
|
||||||
[self.dataStore rearrangeObjects];
|
[self.dataStore rearrangeObjects];
|
||||||
} else {
|
|
||||||
[self.undoManager disableUndoRegistration];
|
|
||||||
[self.undoManager undoNestedGroup];
|
|
||||||
[self.undoManager enableUndoRegistration];
|
|
||||||
}
|
}
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
@@ -213,7 +243,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
|
|
||||||
/// Begin drag-n-drop operation by copying selected nodes to memory
|
/// Begin drag-n-drop operation by copying selected nodes to memory
|
||||||
- (BOOL)outlineView:(NSOutlineView *)outlineView writeItems:(NSArray *)items toPasteboard:(NSPasteboard *)pboard {
|
- (BOOL)outlineView:(NSOutlineView *)outlineView writeItems:(NSArray *)items toPasteboard:(NSPasteboard *)pboard {
|
||||||
[self.undoManager beginUndoGrouping];
|
[self beginCoreDataChange];
|
||||||
[pboard declareTypes:[NSArray arrayWithObject:dragNodeType] owner:self];
|
[pboard declareTypes:[NSArray arrayWithObject:dragNodeType] owner:self];
|
||||||
[pboard setString:@"dragging" forType:dragNodeType];
|
[pboard setString:@"dragging" forType:dragNodeType];
|
||||||
self.currentlyDraggedNodes = items;
|
self.currentlyDraggedNodes = items;
|
||||||
@@ -222,14 +252,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
|
|
||||||
/// Finish drag-n-drop operation by saving changes to persistent store
|
/// Finish drag-n-drop operation by saving changes to persistent store
|
||||||
- (void)outlineView:(NSOutlineView *)outlineView draggingSession:(NSDraggingSession *)session endedAtPoint:(NSPoint)screenPoint operation:(NSDragOperation)operation {
|
- (void)outlineView:(NSOutlineView *)outlineView draggingSession:(NSDraggingSession *)session endedAtPoint:(NSPoint)screenPoint operation:(NSDragOperation)operation {
|
||||||
[self.undoManager endUndoGrouping];
|
[self endCoreDataChangeShouldUndo:NO];
|
||||||
if (self.dataStore.managedObjectContext.hasChanges) {
|
|
||||||
[self saveChanges];
|
|
||||||
} else {
|
|
||||||
[self.undoManager disableUndoRegistration];
|
|
||||||
[self.undoManager undoNestedGroup];
|
|
||||||
[self.undoManager enableUndoRegistration];
|
|
||||||
}
|
|
||||||
self.currentlyDraggedNodes = nil;
|
self.currentlyDraggedNodes = nil;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,17 +341,13 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
/// Perform undo operation and redraw UI & menu bar unread count
|
/// Perform undo operation and redraw UI & menu bar unread count
|
||||||
- (void)undo:(id)sender {
|
- (void)undo:(id)sender {
|
||||||
[self.undoManager undo];
|
[self.undoManager undo];
|
||||||
[self saveChanges];
|
[self saveWithUnpredictableChange];
|
||||||
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
|
|
||||||
[self.dataStore rearrangeObjects]; // update ordering
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Perform redo operation and redraw UI & menu bar unread count
|
/// Perform redo operation and redraw UI & menu bar unread count
|
||||||
- (void)redo:(id)sender {
|
- (void)redo:(id)sender {
|
||||||
[self.undoManager redo];
|
[self.undoManager redo];
|
||||||
[self saveChanges];
|
[self saveWithUnpredictableChange];
|
||||||
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
|
|
||||||
[self.dataStore rearrangeObjects]; // update ordering
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// User pressed enter; open edit dialog for selected item.
|
/// User pressed enter; open edit dialog for selected item.
|
||||||
@@ -362,7 +381,6 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
}
|
}
|
||||||
[[NSPasteboard generalPasteboard] clearContents];
|
[[NSPasteboard generalPasteboard] clearContents];
|
||||||
[[NSPasteboard generalPasteboard] setString:str forType:NSPasteboardTypeString];
|
[[NSPasteboard generalPasteboard] setString:str forType:NSPasteboardTypeString];
|
||||||
NSLog(@"%@", str); // TODO: drag-n-drop feed to opml?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
|
|
||||||
- (IBAction)fixCache:(NSButton *)sender {
|
- (IBAction)fixCache:(NSButton *)sender {
|
||||||
[StoreCoordinator deleteUnreferencedFeeds];
|
[StoreCoordinator deleteUnreferencedFeeds];
|
||||||
[StoreCoordinator restoreFeedCountsAndIndexPaths];
|
[StoreCoordinator restoreFeedCountsAndIndexPaths:nil];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (IBAction)changeMenuBarIconSetting:(NSButton*)sender {
|
- (IBAction)changeMenuBarIconSetting:(NSButton*)sender {
|
||||||
|
|||||||
@@ -26,6 +26,9 @@
|
|||||||
@property (readonly) BOOL didCloseAndSave;
|
@property (readonly) BOOL didCloseAndSave;
|
||||||
@property (readonly) BOOL didCloseAndCancel;
|
@property (readonly) BOOL didCloseAndCancel;
|
||||||
|
|
||||||
+ (instancetype)modalWithView:(NSView*)content;
|
- (instancetype)initWithContentRect:(NSRect)contentRect styleMask:(NSWindowStyleMask)style backing:(NSBackingStoreType)backingStoreType defer:(BOOL)flag NS_UNAVAILABLE;
|
||||||
|
- (instancetype)initWithView:(NSView*)content NS_DESIGNATED_INITIALIZER;
|
||||||
|
|
||||||
- (void)setDoneEnabled:(BOOL)accept;
|
- (void)setDoneEnabled:(BOOL)accept;
|
||||||
|
- (void)extendContentViewBy:(CGFloat)dy;
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
#import "ModalSheet.h"
|
#import "ModalSheet.h"
|
||||||
|
|
||||||
@interface ModalSheet()
|
@interface ModalSheet()
|
||||||
@property (strong) NSButton *btnDone;
|
@property (weak) NSButton *btnDone;
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@implementation ModalSheet
|
@implementation ModalSheet
|
||||||
@@ -52,15 +52,13 @@
|
|||||||
[self.sheetParent endSheet:self returnCode:response];
|
[self.sheetParent endSheet:self returnCode:response];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Designated initializer for @c ModalSheet.
|
Designated initializer for @c ModalSheet. 'Done' and 'Cancel' button will be added automatically.
|
||||||
|
|
||||||
@param content @c NSView will be displayed in dialog box. 'Done' and 'Cancel' button will be added automatically.
|
@param content @c NSView will be displayed in dialog box.
|
||||||
*/
|
*/
|
||||||
+ (instancetype)modalWithView:(NSView*)content {
|
- (instancetype)initWithView:(NSView*)content {
|
||||||
static const int padWindow = 20;
|
static const int padWindow = 20;
|
||||||
static const int padButtons = 12;
|
|
||||||
static const int minWidth = 320;
|
static const int minWidth = 320;
|
||||||
static const int maxWidth = 1200;
|
static const int maxWidth = 1200;
|
||||||
|
|
||||||
@@ -68,48 +66,67 @@
|
|||||||
if (prevWidth < minWidth) prevWidth = minWidth;
|
if (prevWidth < minWidth) prevWidth = minWidth;
|
||||||
else if (prevWidth > maxWidth) prevWidth = maxWidth;
|
else if (prevWidth > maxWidth) prevWidth = maxWidth;
|
||||||
|
|
||||||
NSRect cFrame = NSMakeRect(padWindow, padWindow, prevWidth, content.frame.size.height);
|
NSSize contentSize = NSMakeSize(prevWidth, content.frame.size.height);
|
||||||
NSRect wFrame = CGRectInset(cFrame, -padWindow, -padWindow);
|
[content setFrameSize:contentSize];
|
||||||
|
|
||||||
|
NSSize wSize = NSMakeSize(contentSize.width + 2 * padWindow, contentSize.height + 2 * padWindow);
|
||||||
|
|
||||||
NSWindowStyleMask style = NSWindowStyleMaskTitled | NSWindowStyleMaskResizable | NSWindowStyleMaskFullSizeContentView;
|
NSWindowStyleMask style = NSWindowStyleMaskTitled | NSWindowStyleMaskResizable | NSWindowStyleMaskFullSizeContentView;
|
||||||
ModalSheet *sheet = [[super alloc] initWithContentRect:wFrame styleMask:style backing:NSBackingStoreBuffered defer:NO];
|
self = [super initWithContentRect:NSMakeRect(0, 0, wSize.width, wSize.height) styleMask:style backing:NSBackingStoreBuffered defer:NO];
|
||||||
|
if (self) {
|
||||||
|
NSButton *btnDone = [NSButton buttonWithTitle:NSLocalizedString(@"Done", nil) target:self action:@selector(didTapDoneButton:)];
|
||||||
|
NSButton *btnCancel = [NSButton buttonWithTitle:NSLocalizedString(@"Cancel", nil) target:self action:@selector(didTapCancelButton:)];
|
||||||
|
btnDone.keyEquivalent = @"\r"; // Enter / Return
|
||||||
|
btnCancel.keyEquivalent = [NSString stringWithFormat:@"%c", 0x1b]; // ESC
|
||||||
|
|
||||||
|
// Make room for buttons
|
||||||
|
wSize.height += btnDone.frame.size.height;
|
||||||
|
[self setContentSize:wSize];
|
||||||
|
|
||||||
|
// Restrict resizing to width only (after setContentSize:)
|
||||||
|
self.minSize = NSMakeSize(minWidth + 2 * padWindow, wSize.height);
|
||||||
|
self.maxSize = NSMakeSize(maxWidth + 2 * padWindow, wSize.height);
|
||||||
|
|
||||||
|
// Content view (set origin after setContentSize:)
|
||||||
|
[content setFrameOrigin:NSMakePoint(padWindow, wSize.height - padWindow - contentSize.height)];
|
||||||
|
[self.contentView addSubview:content];
|
||||||
|
|
||||||
// Respond buttons
|
// Respond buttons
|
||||||
sheet.btnDone = [NSButton buttonWithTitle:NSLocalizedString(@"Done", nil) target:sheet action:@selector(didTapDoneButton:)];
|
[self placeButtons:@[btnDone, btnCancel] inBottomRightCornerWithPadding:padWindow];
|
||||||
sheet.btnDone.keyEquivalent = @"\r"; // Enter / Return
|
[self.contentView addSubview:btnCancel];
|
||||||
sheet.btnDone.autoresizingMask = NSViewMinXMargin | NSViewMaxYMargin;
|
[self.contentView addSubview:btnDone];
|
||||||
|
self.btnDone = btnDone;
|
||||||
NSButton *btnCancel = [NSButton buttonWithTitle:NSLocalizedString(@"Cancel", nil) target:sheet action:@selector(didTapCancelButton:)];
|
}
|
||||||
btnCancel.keyEquivalent = [NSString stringWithFormat:@"%c", 0x1b]; // ESC
|
return self;
|
||||||
btnCancel.autoresizingMask = NSViewMinXMargin | NSViewMaxYMargin;
|
|
||||||
|
|
||||||
NSRect align = [sheet.btnDone alignmentRectForFrame:sheet.btnDone.frame];
|
|
||||||
align.origin.x = wFrame.size.width - align.size.width - padWindow;
|
|
||||||
align.origin.y = padWindow;
|
|
||||||
[sheet.btnDone setFrameOrigin:[sheet.btnDone frameForAlignmentRect:align].origin];
|
|
||||||
|
|
||||||
align.origin.x -= [btnCancel alignmentRectForFrame:btnCancel.frame].size.width + padButtons;
|
|
||||||
[btnCancel setFrameOrigin:[btnCancel frameForAlignmentRect:align].origin];
|
|
||||||
|
|
||||||
// this is equivalent, however I'm not sure if these values will change in a future OS
|
|
||||||
// [btnDone setFrameOrigin:NSMakePoint(wFrame.size.width - btnDone.frame.size.width - 12, 13)]; // =20 with alignment
|
|
||||||
// [btnCancel setFrameOrigin:NSMakePoint(btnDone.frame.origin.x - btnCancel.frame.size.width, 13)];
|
|
||||||
|
|
||||||
// add all UI elements to the window view
|
|
||||||
content.frame = cFrame;
|
|
||||||
[sheet.contentView addSubview:content];
|
|
||||||
[sheet.contentView addSubview:sheet.btnDone];
|
|
||||||
[sheet.contentView addSubview:btnCancel];
|
|
||||||
|
|
||||||
// add respond buttons to the window height
|
|
||||||
wFrame.size.height += align.size.height + padButtons;
|
|
||||||
[sheet setContentSize:wFrame.size];
|
|
||||||
|
|
||||||
// constraints on resizing
|
|
||||||
sheet.minSize = NSMakeSize(minWidth + 2 * padWindow, wFrame.size.height);
|
|
||||||
sheet.maxSize = NSMakeSize(maxWidth, wFrame.size.height);
|
|
||||||
return sheet;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Buttons will stick to the right margin and bottom margin when resizing. Also sets autoresizingMask.
|
||||||
|
|
||||||
|
@param buttons First item is rightmost button. Next buttons will be appended left of that button and so on.
|
||||||
|
@param padding Distance between button and right / bottom edge.
|
||||||
|
*/
|
||||||
|
- (void)placeButtons:(NSArray<NSButton*> *)buttons inBottomRightCornerWithPadding:(int)padding {
|
||||||
|
NSEdgeInsets edge = buttons.firstObject.alignmentRectInsets;
|
||||||
|
NSPoint p = NSMakePoint(self.contentView.frame.size.width - padding + edge.right, padding - edge.bottom);
|
||||||
|
for (NSButton *btn in buttons) {
|
||||||
|
p.x -= btn.frame.size.width;
|
||||||
|
[btn setFrameOrigin:p];
|
||||||
|
btn.autoresizingMask = NSViewMinXMargin | NSViewMaxYMargin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Resize modal window by @c dy. Makes room for additional content. Use negative values to shrink window.
|
||||||
|
*/
|
||||||
|
- (void)extendContentViewBy:(CGFloat)dy {
|
||||||
|
self.minSize = NSMakeSize(self.minSize.width, self.minSize.height + dy);
|
||||||
|
self.maxSize = NSMakeSize(self.maxSize.width, self.maxSize.height + dy);
|
||||||
|
NSRect r = self.frame;
|
||||||
|
r.size.height += dy;
|
||||||
|
[self setFrame:r display:YES animate:YES];
|
||||||
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
+ (NSArray<FeedGroup*>*)sortedListOfRootObjectsInContext:(NSManagedObjectContext*)moc;
|
+ (NSArray<FeedGroup*>*)sortedListOfRootObjectsInContext:(NSManagedObjectContext*)moc;
|
||||||
// Restore sound state
|
// Restore sound state
|
||||||
+ (void)deleteUnreferencedFeeds;
|
+ (void)deleteUnreferencedFeeds;
|
||||||
+ (void)restoreFeedCountsAndIndexPaths;
|
+ (void)restoreFeedCountsAndIndexPaths:(NSArray<NSManagedObjectID*>*)list;
|
||||||
+ (NSArray<Feed*>*)listOfMissingFeedsInContext:(NSManagedObjectContext*)moc;
|
+ (NSArray<Feed*>*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc;
|
||||||
|
+ (NSArray<Feed*>*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc;
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
NSManagedObjectContext *context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType];
|
NSManagedObjectContext *context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType];
|
||||||
[context setParentContext:[self getMainContext]];
|
[context setParentContext:[self getMainContext]];
|
||||||
context.undoManager = nil;
|
context.undoManager = nil;
|
||||||
//context.automaticallyMergesChangesFromParent = YES;
|
context.automaticallyMergesChangesFromParent = YES;
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
NSExpression *exp = [NSExpression expressionForFunction:@"sum:" arguments:@[[NSExpression expressionForKeyPath:@"unreadCount"]]];
|
NSExpression *exp = [NSExpression expressionForFunction:@"sum:" arguments:@[[NSExpression expressionForKeyPath:@"unreadCount"]]];
|
||||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
|
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
|
||||||
if (str && str.length > 0)
|
if (str && str.length > 0)
|
||||||
fr.predicate = [NSPredicate predicateWithFormat:@"indexPath BEGINSWITH %@", str];
|
fr.predicate = [NSPredicate predicateWithFormat:@"indexPath BEGINSWITH %@", [str stringByAppendingString:@"."]];
|
||||||
return [self fetchInteger:moc request:fr expression:exp];
|
return [self fetchInteger:moc request:fr expression:exp];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,29 +192,40 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
Iterate over all @c Feed and re-calculate @c unreadCount, @c articleCount and @c indexPath.
|
Iterate over all @c Feed and re-calculate @c unreadCount, @c articleCount and @c indexPath.
|
||||||
|
Restore will happend on the main context.
|
||||||
|
|
||||||
|
@param list A list of @c Feed objectIDs. Acts like a filter, if @c nil performs a fetch on all feed items.
|
||||||
*/
|
*/
|
||||||
+ (void)restoreFeedCountsAndIndexPaths {
|
+ (void)restoreFeedCountsAndIndexPaths:(NSArray<NSManagedObjectID*>*)list {
|
||||||
NSManagedObjectContext *moc = [self getMainContext];
|
NSManagedObjectContext *moc = [self getMainContext];
|
||||||
NSArray *result = [self fetchAllRows:[NSFetchRequest fetchRequestWithEntityName: Feed.entity.name] inContext:moc];
|
if (!list) {
|
||||||
[moc performBlock:^{
|
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
|
||||||
for (Feed *feed in result) {
|
[fr setResultType:NSManagedObjectIDResultType];
|
||||||
int16_t totalCount = (int16_t)feed.articles.count;
|
list = [self fetchAllRows:fr inContext:moc];
|
||||||
int16_t unreadCount = (int16_t)[[feed.articles 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
|
|
||||||
[feed calculateAndSetIndexPathString];
|
|
||||||
}
|
}
|
||||||
|
[moc performBlock:^{
|
||||||
|
for (NSManagedObjectID *moi in list) {
|
||||||
|
Feed *f = [moc objectWithID:moi];
|
||||||
|
if ([f isKindOfClass:[Feed class]])
|
||||||
|
[f resetArticleCountAndIndexPathString];
|
||||||
|
}
|
||||||
|
[self saveContext:moc andParent:YES];
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @return All @c Feed items where @c articles.count @c == @c 0
|
/// @return All @c Feed items where @c articles.count @c == @c 0
|
||||||
+ (NSArray<Feed*>*)listOfMissingFeedsInContext:(NSManagedObjectContext*)moc {
|
+ (NSArray<Feed*>*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc {
|
||||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
|
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
|
||||||
// More accurate but with subquery on FeedArticle: "count(articles) == 0"
|
// More accurate but with subquery on FeedArticle: "count(articles) == 0"
|
||||||
fr.predicate = [NSPredicate predicateWithFormat:@"articleCount == 0"];
|
fr.predicate = [NSPredicate predicateWithFormat:@"articleCount == 0"];
|
||||||
return [self fetchAllRows:fr inContext:moc];
|
return [self fetchAllRows:fr inContext:moc];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return All @c Feed items where @c icon is @c nil.
|
||||||
|
+ (NSArray<Feed*>*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc {
|
||||||
|
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
|
||||||
|
fr.predicate = [NSPredicate predicateWithFormat:@"icon = NULL"];
|
||||||
|
return [self fetchAllRows:fr inContext:moc];
|
||||||
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
Reference in New Issue
Block a user