diff --git a/baRSS.xcodeproj/project.pbxproj b/baRSS.xcodeproj/project.pbxproj index 9b9940c..9552e18 100644 --- a/baRSS.xcodeproj/project.pbxproj +++ b/baRSS.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 54195886218E1BDB00581B79 /* NSMenu+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54195885218E1BDB00581B79 /* NSMenu+Ext.m */; }; 54209E942117325100F3B5EF /* DrawImage.m in Sources */ = {isa = PBXBuildFile; fileRef = 54209E932117325100F3B5EF /* DrawImage.m */; }; 543695D8214F1F2700DA979D /* NSMenuItem+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 543695D7214F1F2700DA979D /* NSMenuItem+Ext.m */; }; + 544936FB21F1E66100DEE9AA /* Statistics.m in Sources */ = {isa = PBXBuildFile; fileRef = 544936FA21F1E66100DEE9AA /* Statistics.m */; }; 544B011A2114B41200386E5C /* ModalSheet.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B01192114B41200386E5C /* ModalSheet.m */; }; 544B011D2114EE9100386E5C /* AppHook.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B011C2114EE9100386E5C /* AppHook.m */; }; 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 = ""; }; 543695D6214F1F2700DA979D /* NSMenuItem+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSMenuItem+Ext.h"; sourceTree = ""; }; 543695D7214F1F2700DA979D /* NSMenuItem+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSMenuItem+Ext.m"; sourceTree = ""; }; + 544936F921F1E66100DEE9AA /* Statistics.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Statistics.h; sourceTree = ""; }; + 544936FA21F1E66100DEE9AA /* Statistics.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Statistics.m; sourceTree = ""; }; 544B01182114B41200386E5C /* ModalSheet.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ModalSheet.h; sourceTree = ""; }; 544B01192114B41200386E5C /* ModalSheet.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ModalSheet.m; sourceTree = ""; }; 544B011B2114EE9100386E5C /* AppHook.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppHook.h; sourceTree = ""; }; @@ -174,6 +177,17 @@ path = "Status Bar Menu"; sourceTree = ""; }; + 544936F721F1E51E00DEE9AA /* Helper */ = { + isa = PBXGroup; + children = ( + 54209E922117325100F3B5EF /* DrawImage.h */, + 54209E932117325100F3B5EF /* DrawImage.m */, + 544936F921F1E66100DEE9AA /* Statistics.h */, + 544936FA21F1E66100DEE9AA /* Statistics.m */, + ); + path = Helper; + sourceTree = ""; + }; 544FBD4321064AEB008A260C /* Frameworks */ = { isa = PBXGroup; children = ( @@ -241,8 +255,7 @@ 54FE73CF21220DEC003EAC65 /* StoreCoordinator.m */, 54ACC29321061E270020715F /* FeedDownload.h */, 54ACC29421061E270020715F /* FeedDownload.m */, - 54209E922117325100F3B5EF /* DrawImage.h */, - 54209E932117325100F3B5EF /* DrawImage.m */, + 544936F721F1E51E00DEE9AA /* Helper */, 54195880218A05E700581B79 /* Categories */, 546FC44D2118B357007CC3A3 /* Preferences */, 54ACC28521061B3C0020715F /* Assets.xcassets */, @@ -401,6 +414,7 @@ 546FC44321189975007CC3A3 /* SettingsGeneral.m in Sources */, 54ACC29521061E270020715F /* FeedDownload.m in Sources */, 5477D34E21233C62002BA27F /* FeedGroup+Ext.m in Sources */, + 544936FB21F1E66100DEE9AA /* Statistics.m in Sources */, 540F704521B6C16C0022E69D /* FeedMeta+Ext.m in Sources */, 54ACC28C21061B3C0020715F /* main.m in Sources */, 54FE73D3212316CD003EAC65 /* BarMenu.m in Sources */, diff --git a/baRSS/Categories/Feed+Ext.h b/baRSS/Categories/Feed+Ext.h index e0fb8f9..9e5c67b 100644 --- a/baRSS/Categories/Feed+Ext.h +++ b/baRSS/Categories/Feed+Ext.h @@ -29,6 +29,7 @@ + (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context; + (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc; - (void)calculateAndSetIndexPathString; +- (void)resetArticleCountAndIndexPathString; - (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag; // Article properties - (NSArray*)sortedArticles; diff --git a/baRSS/Categories/Feed+Ext.m b/baRSS/Categories/Feed+Ext.m index e79a456..e532da7 100644 --- a/baRSS/Categories/Feed+Ext.m +++ b/baRSS/Categories/Feed+Ext.m @@ -57,6 +57,17 @@ 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 - @@ -144,6 +155,8 @@ fa.author = entry.author; fa.link = entry.link; fa.published = entry.datePublished; + if (!fa.published) + fa.published = entry.dateModified; [self addArticlesObject:fa]; return fa; } diff --git a/baRSS/FeedDownload.m b/baRSS/FeedDownload.m index c392577..49f58d9 100644 --- a/baRSS/FeedDownload.m +++ b/baRSS/FeedDownload.m @@ -133,6 +133,13 @@ static BOOL _nextUpdateIsForced = NO; [FeedDownload batchUpdateFeeds:list showErrorAlert:NO finally:^(NSArray *successful, NSArray *failed) { [self saveContext:moc andPostChanges:successful]; [moc reset]; + if (updateAll) { // forced update will also download missing feed icons + NSArray *missingIcons = [StoreCoordinator listOfFeedsMissingIconsInContext:moc]; + [self batchDownloadFavicons:missingIcons replaceExisting:NO finally:^{ + [self saveContext:moc andPostChanges:successful]; + [moc reset]; + }]; + } [self resumeUpdates]; // always reset the timer }]; } @@ -285,14 +292,20 @@ static BOOL _nextUpdateIsForced = NO; } dispatch_group_enter(group); [self parseFeedRequest:[self newRequest:feed.meta] xmlBlock:nil feedBlock:^(RSParsedFeed *rss, NSError *error, NSHTTPURLResponse *response) { - if (error) { - if (alert) [NSApp presentError:error]; - [feed.meta setErrorAndPostponeSchedule]; - [failed addObject:feed]; - } else { - [feed.meta setSucessfulWithResponse:response]; - if (rss) [feed updateWithRSS:rss postUnreadCountChange:YES]; - [successful addObject:feed]; // will be added even if statusCode == 304 (rss == nil) + if (!feed.isDeleted) { + if (error) { + if (alert) { + NSAlert *alertPopup = [NSAlert alertWithError:error]; + alertPopup.informativeText = [NSString stringWithFormat:@"Error loading source: %@", response.URL.absoluteString]; + [alertPopup runModal]; + } + [feed.meta setErrorAndPostponeSchedule]; + [failed addObject:feed]; + } else { + [feed.meta setSucessfulWithResponse:response]; + if (rss) [feed updateWithRSS:rss postUnreadCountChange:YES]; + [successful addObject:feed]; // will be added even if statusCode == 304 (rss == nil) + } } dispatch_group_leave(group); }]; @@ -421,6 +434,8 @@ static BOOL _nextUpdateIsForced = NO; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ NSURL *favURL = [[self hostURL:urlStr] URLByAppendingPathComponent:@"favicon.ico"]; // 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]; if (!img || ![img isValid]) img = nil; diff --git a/baRSS/DrawImage.h b/baRSS/Helper/DrawImage.h similarity index 100% rename from baRSS/DrawImage.h rename to baRSS/Helper/DrawImage.h diff --git a/baRSS/DrawImage.m b/baRSS/Helper/DrawImage.m similarity index 100% rename from baRSS/DrawImage.m rename to baRSS/Helper/DrawImage.m diff --git a/baRSS/Helper/Statistics.h b/baRSS/Helper/Statistics.h new file mode 100644 index 0000000..ddd49a9 --- /dev/null +++ b/baRSS/Helper/Statistics.h @@ -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 + +@protocol RefreshIntervalButtonDelegate +@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 *)list; ++ (NSView*)viewForRefreshInterval:(NSDictionary*)info articleCount:(NSUInteger)count callback:(nullable id)callback; +@end diff --git a/baRSS/Helper/Statistics.m b/baRSS/Helper/Statistics.m new file mode 100644 index 0000000..2d28849 --- /dev/null +++ b/baRSS/Helper/Statistics.m @@ -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 *)list { + if (!list || list.count == 0) + return nil; + + NSDate *earliest = [NSDate distantFuture]; + NSDate *latest = [NSDate distantPast]; + NSDate *prev = nil; + NSMutableArray *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)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)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 diff --git a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m index 2039665..48b8fcc 100644 --- a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m +++ b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m @@ -26,6 +26,8 @@ #import "Feed+Ext.h" #import "FeedMeta+Ext.h" #import "FeedGroup+Ext.h" +#import "Statistics.h" +#import #pragma mark - ModalEditDialog - @@ -46,7 +48,7 @@ /// @return New @c ModalSheet with its subclass @c .view property as dialog content. - (ModalSheet *)getModalSheet { if (!self.modalSheet) - self.modalSheet = [ModalSheet modalWithView:self.view]; + self.modalSheet = [[ModalSheet alloc] initWithView:self.view]; return self.modalSheet; } /// This method should be overridden by subclasses. Used to save changes to persistent store. @@ -60,7 +62,7 @@ #pragma mark - ModalFeedEdit - -@interface ModalFeedEdit() +@interface ModalFeedEdit() @property (weak) IBOutlet NSTextField *url; @property (weak) IBOutlet NSTextField *name; @property (weak) IBOutlet NSTextField *refreshNum; @@ -69,6 +71,7 @@ @property (weak) IBOutlet NSProgressIndicator *spinnerName; @property (weak) IBOutlet NSButton *warningIndicator; @property (weak) IBOutlet NSPopover *warningPopover; +@property (strong) NSView *statisticsView; @property (copy) NSString *previousURL; // check if changed and avoid multiple download @property (copy) NSString *httpDate; @@ -105,6 +108,7 @@ unit = self.refreshUnit.numberOfItems - 1; [self.refreshUnit selectItemAtIndex:unit]; self.warningIndicator.image = [fg.feed iconImage16]; + [self statsForCoreDataObject]; } #pragma mark - Edit Feed Data @@ -189,6 +193,7 @@ NSPoint belowURL = NSMakePoint(0,self.url.frame.size.height); if ([menu popUpMenuPositioningItem:nil atLocation:belowURL inView:self.url]) { 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 nil; // user selection canceled @@ -214,6 +219,8 @@ if (parsedTitle.length > 0 && [self.name.stringValue isEqualToString:@""]) { 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) if (self.feedError) { [self finishDownloadWithFavicon:[NSImage imageNamed:NSImageNameCaution]]; @@ -243,6 +250,70 @@ [self.modalSheet setDoneEnabled:YES]; } +#pragma mark - Feed Statistics + +/// Perform statistics on newly downloaded feed item +- (void)statsForDownloadObject { + NSMutableArray *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 *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 diff --git a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.xib b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.xib index 0ae2aea..c4b6895 100644 --- a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.xib +++ b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.xib @@ -23,7 +23,7 @@ - + diff --git a/baRSS/Preferences/Feeds Tab/OpmlExport.m b/baRSS/Preferences/Feeds Tab/OpmlExport.m index f59fcac..dd1c549 100644 --- a/baRSS/Preferences/Feeds Tab/OpmlExport.m +++ b/baRSS/Preferences/Feeds Tab/OpmlExport.m @@ -45,7 +45,7 @@ /// Display Save File Panel to select export destination. All feeds from core data will be exported. + (void)showExportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc { 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.allowsOtherFileTypes = YES; 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. + (void)showImportDialog:(NSWindow*)window withTreeController:(NSTreeController*)tree { NSManagedObjectContext *moc = tree.managedObjectContext; + //[moc refreshAllObjects]; [moc.undoManager beginUndoGrouping]; [self showImportDialog:window withContext:moc success:^(NSArray *added) { [StoreCoordinator saveContext:moc andParent:YES]; @@ -204,7 +205,9 @@ @return Save this string to file. */ + (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]; @autoreleasepool { NSArray *arr = [StoreCoordinator sortedListOfRootObjectsInContext:moc]; @@ -246,10 +249,13 @@ #pragma mark - Helper -/// @return Date formatted as @c yyyy-MM-dd -+ (NSString*)currentDayAsString { - NSDateComponents *now = [[NSCalendar currentCalendar] components:NSCalendarUnitDay | NSCalendarUnitMonth | NSCalendarUnitYear fromDate:[NSDate date]]; - return [NSString stringWithFormat:@"%04ld-%02ld-%02ld", now.year, now.month, now.day]; +/// @param flag If @c YES use long internet format for opml file. If @c NO use short format as filename. ++ (NSString*)currentDayAsStringISO8601:(BOOL)flag { + if (flag) + 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. diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeeds.m b/baRSS/Preferences/Feeds Tab/SettingsFeeds.m index 5b3834f..0cfeb96 100644 --- a/baRSS/Preferences/Feeds Tab/SettingsFeeds.m +++ b/baRSS/Preferences/Feeds Tab/SettingsFeeds.m @@ -38,6 +38,7 @@ @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... static NSString *dragNodeType = @"baRSS-feed-drag"; @@ -52,37 +53,80 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; self.dataStore.managedObjectContext = [StoreCoordinator createChildContext]; 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 *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 +/// Add feed button. - (IBAction)addFeed:(id)sender { [self showModalForFeedGroup:nil isGroupEdit:NO]; } +/// Add group button. - (IBAction)addGroup:(id)sender { [self showModalForFeedGroup:nil isGroupEdit:YES]; } +/// Add separator button. - (IBAction)addSeparator:(id)sender { - [self.undoManager beginUndoGrouping]; + [self beginCoreDataChange]; [self insertFeedGroupAtSelection:SEPARATOR].name = @"---"; - [self.undoManager endUndoGrouping]; - [self saveChanges]; + [self endCoreDataChangeShouldUndo:NO]; } -/// Remove user selected item from persistent store. +/// Remove feed button. User has selected one or more item in outline view. - (IBAction)remove:(id)sender { - [self.undoManager beginUndoGrouping]; + [self beginCoreDataChange]; NSArray *parentNodes = [self.dataStore.selectedNodes valueForKeyPath:@"parentNode"]; [self.dataStore remove:sender]; for (NSTreeNode *parent in parentNodes) { [self restoreOrderingAndIndexPathStr:parent]; } - [self.undoManager endUndoGrouping]; - [self saveChanges]; + [self endCoreDataChangeShouldUndo:NO]; [[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 } +/// Share menu button. Currently only import & export feeds as OPML. - (IBAction)shareMenu:(NSButton*)sender { if (!sender.menu) { 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 -/// 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. @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 { if (fg.type == SEPARATOR) return; - [self.undoManager beginUndoGrouping]; + [self beginCoreDataChange]; if (!fg || ![fg isKindOfClass:[FeedGroup class]]) { 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) { if (returnCode == NSModalResponseOK) { [editDialog applyChangesToCoreDataObject]; - [self.undoManager endUndoGrouping]; - } else { - [self.undoManager endUndoGrouping]; - [self.dataStore.managedObjectContext rollback]; } - BOOL hasChanges = [self.dataStore.managedObjectContext hasChanges]; - if (hasChanges) { - [self saveChanges]; + if ([self endCoreDataChangeShouldUndo:(returnCode != NSModalResponseOK)]) { [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 - (BOOL)outlineView:(NSOutlineView *)outlineView writeItems:(NSArray *)items toPasteboard:(NSPasteboard *)pboard { - [self.undoManager beginUndoGrouping]; + [self beginCoreDataChange]; [pboard declareTypes:[NSArray arrayWithObject:dragNodeType] owner:self]; [pboard setString:@"dragging" forType:dragNodeType]; self.currentlyDraggedNodes = items; @@ -222,14 +252,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; /// Finish drag-n-drop operation by saving changes to persistent store - (void)outlineView:(NSOutlineView *)outlineView draggingSession:(NSDraggingSession *)session endedAtPoint:(NSPoint)screenPoint operation:(NSDragOperation)operation { - [self.undoManager endUndoGrouping]; - if (self.dataStore.managedObjectContext.hasChanges) { - [self saveChanges]; - } else { - [self.undoManager disableUndoRegistration]; - [self.undoManager undoNestedGroup]; - [self.undoManager enableUndoRegistration]; - } + [self endCoreDataChangeShouldUndo:NO]; self.currentlyDraggedNodes = nil; } @@ -318,17 +341,13 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; /// Perform undo operation and redraw UI & menu bar unread count - (void)undo:(id)sender { [self.undoManager undo]; - [self saveChanges]; - [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil]; - [self.dataStore rearrangeObjects]; // update ordering + [self saveWithUnpredictableChange]; } /// Perform redo operation and redraw UI & menu bar unread count - (void)redo:(id)sender { [self.undoManager redo]; - [self saveChanges]; - [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil]; - [self.dataStore rearrangeObjects]; // update ordering + [self saveWithUnpredictableChange]; } /// User pressed enter; open edit dialog for selected item. @@ -362,7 +381,6 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; } [[NSPasteboard generalPasteboard] clearContents]; [[NSPasteboard generalPasteboard] setString:str forType:NSPasteboardTypeString]; - NSLog(@"%@", str); // TODO: drag-n-drop feed to opml? } /** diff --git a/baRSS/Preferences/General Tab/SettingsGeneral.m b/baRSS/Preferences/General Tab/SettingsGeneral.m index dcf27cb..6775e2c 100644 --- a/baRSS/Preferences/General Tab/SettingsGeneral.m +++ b/baRSS/Preferences/General Tab/SettingsGeneral.m @@ -61,7 +61,7 @@ - (IBAction)fixCache:(NSButton *)sender { [StoreCoordinator deleteUnreferencedFeeds]; - [StoreCoordinator restoreFeedCountsAndIndexPaths]; + [StoreCoordinator restoreFeedCountsAndIndexPaths:nil]; } - (IBAction)changeMenuBarIconSetting:(NSButton*)sender { diff --git a/baRSS/Preferences/ModalSheet.h b/baRSS/Preferences/ModalSheet.h index 33d8e68..309d6f7 100644 --- a/baRSS/Preferences/ModalSheet.h +++ b/baRSS/Preferences/ModalSheet.h @@ -26,6 +26,9 @@ @property (readonly) BOOL didCloseAndSave; @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)extendContentViewBy:(CGFloat)dy; @end diff --git a/baRSS/Preferences/ModalSheet.m b/baRSS/Preferences/ModalSheet.m index c4d6a25..6d54cf2 100644 --- a/baRSS/Preferences/ModalSheet.m +++ b/baRSS/Preferences/ModalSheet.m @@ -23,7 +23,7 @@ #import "ModalSheet.h" @interface ModalSheet() -@property (strong) NSButton *btnDone; +@property (weak) NSButton *btnDone; @end @implementation ModalSheet @@ -52,15 +52,13 @@ [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 padButtons = 12; static const int minWidth = 320; static const int maxWidth = 1200; @@ -68,48 +66,67 @@ if (prevWidth < minWidth) prevWidth = minWidth; else if (prevWidth > maxWidth) prevWidth = maxWidth; - NSRect cFrame = NSMakeRect(padWindow, padWindow, prevWidth, content.frame.size.height); - NSRect wFrame = CGRectInset(cFrame, -padWindow, -padWindow); + NSSize contentSize = NSMakeSize(prevWidth, content.frame.size.height); + [content setFrameSize:contentSize]; + + NSSize wSize = NSMakeSize(contentSize.width + 2 * padWindow, contentSize.height + 2 * padWindow); NSWindowStyleMask style = NSWindowStyleMaskTitled | NSWindowStyleMaskResizable | NSWindowStyleMaskFullSizeContentView; - ModalSheet *sheet = [[super alloc] initWithContentRect:wFrame styleMask:style backing:NSBackingStoreBuffered defer:NO]; - - // Respond buttons - sheet.btnDone = [NSButton buttonWithTitle:NSLocalizedString(@"Done", nil) target:sheet action:@selector(didTapDoneButton:)]; - sheet.btnDone.keyEquivalent = @"\r"; // Enter / Return - sheet.btnDone.autoresizingMask = NSViewMinXMargin | NSViewMaxYMargin; - - NSButton *btnCancel = [NSButton buttonWithTitle:NSLocalizedString(@"Cancel", nil) target:sheet action:@selector(didTapCancelButton:)]; - btnCancel.keyEquivalent = [NSString stringWithFormat:@"%c", 0x1b]; // ESC - 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; + 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 + [self placeButtons:@[btnDone, btnCancel] inBottomRightCornerWithPadding:padWindow]; + [self.contentView addSubview:btnCancel]; + [self.contentView addSubview:btnDone]; + self.btnDone = btnDone; + } + return self; } + +/** + 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 *)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 diff --git a/baRSS/StoreCoordinator.h b/baRSS/StoreCoordinator.h index aa0fc5b..0f3469f 100644 --- a/baRSS/StoreCoordinator.h +++ b/baRSS/StoreCoordinator.h @@ -38,6 +38,7 @@ + (NSArray*)sortedListOfRootObjectsInContext:(NSManagedObjectContext*)moc; // Restore sound state + (void)deleteUnreferencedFeeds; -+ (void)restoreFeedCountsAndIndexPaths; -+ (NSArray*)listOfMissingFeedsInContext:(NSManagedObjectContext*)moc; ++ (void)restoreFeedCountsAndIndexPaths:(NSArray*)list; ++ (NSArray*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc; ++ (NSArray*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc; @end diff --git a/baRSS/StoreCoordinator.m b/baRSS/StoreCoordinator.m index 523dae1..f92797d 100644 --- a/baRSS/StoreCoordinator.m +++ b/baRSS/StoreCoordinator.m @@ -40,7 +40,7 @@ NSManagedObjectContext *context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType]; [context setParentContext:[self getMainContext]]; context.undoManager = nil; - //context.automaticallyMergesChangesFromParent = YES; + context.automaticallyMergesChangesFromParent = YES; return context; } @@ -137,7 +137,7 @@ NSExpression *exp = [NSExpression expressionForFunction:@"sum:" arguments:@[[NSExpression expressionForKeyPath:@"unreadCount"]]]; NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name]; if (str && str.length > 0) - fr.predicate = [NSPredicate predicateWithFormat:@"indexPath BEGINSWITH %@", str]; + fr.predicate = [NSPredicate predicateWithFormat:@"indexPath BEGINSWITH %@", [str stringByAppendingString:@"."]]; 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. + 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*)list { NSManagedObjectContext *moc = [self getMainContext]; - NSArray *result = [self fetchAllRows:[NSFetchRequest fetchRequestWithEntityName: Feed.entity.name] inContext:moc]; + if (!list) { + NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name]; + [fr setResultType:NSManagedObjectIDResultType]; + list = [self fetchAllRows:fr inContext:moc]; + } [moc performBlock:^{ - for (Feed *feed in result) { - int16_t totalCount = (int16_t)feed.articles.count; - 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]; + 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 -+ (NSArray*)listOfMissingFeedsInContext:(NSManagedObjectContext*)moc { ++ (NSArray*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc { NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name]; // More accurate but with subquery on FeedArticle: "count(articles) == 0" fr.predicate = [NSPredicate predicateWithFormat:@"articleCount == 0"]; return [self fetchAllRows:fr inContext:moc]; } +/// @return All @c Feed items where @c icon is @c nil. ++ (NSArray*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc { + NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name]; + fr.predicate = [NSPredicate predicateWithFormat:@"icon = NULL"]; + return [self fetchAllRows:fr inContext:moc]; +} + @end