diff --git a/CHANGELOG.md b/CHANGELOG.md index c80744d..11587dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,15 +10,18 @@ and this project does NOT adhere to [Semantic Versioning](https://semver.org/spe - Show users any 5xx server error response and extracted failure reason - 5xx server errors have a reload button which will initiate a new download with the same URL - Adding feed: Cmd+R will reload the same URL -- Settings, Feeds: Cmd+R will reload the data source +- Settings, Feeds: Cmd+R will reload the data source +- Refresh interval string localizations ### Fixed - Changed error message text when user cancels creation of new feed item - Comparing existing articles with nonexistent guid and link -- Adding feed: If URLs can't be resolved in the first run (5xx error), try a second time. E.g., 'Done' click (issue: #5) +- Adding feed: If URLs can't be resolved in the first run (5xx error), try a second time. E.g., 'Done' click (issue: #5) ### Changed - Interface builder files replaced with code equivalent +- Settings, Feeds: Single add button for feeds, groups, and separators +- Refresh interval hotkeys set to: Cmd+1 … Cmd+6 ## [0.9.4] - 2019-04-02 diff --git a/baRSS/Core Data/FeedGroup+Ext.h b/baRSS/Core Data/FeedGroup+Ext.h index b5440c5..ddf41af 100644 --- a/baRSS/Core Data/FeedGroup+Ext.h +++ b/baRSS/Core Data/FeedGroup+Ext.h @@ -48,5 +48,4 @@ typedef NS_ENUM(int16_t, FeedGroupType) { - (BOOL)iterateSorted:(BOOL)ordered overDescendantFeeds:(void(^)(Feed *feed, BOOL* cancel))block; // Printing - (NSString*)readableDescription; -- (nonnull NSString*)refreshString; @end diff --git a/baRSS/Core Data/FeedGroup+Ext.m b/baRSS/Core Data/FeedGroup+Ext.m index ca66785..4bef00e 100644 --- a/baRSS/Core Data/FeedGroup+Ext.m +++ b/baRSS/Core Data/FeedGroup+Ext.m @@ -141,22 +141,10 @@ /// @return Simplified description of the feed object. - (NSString*)readableDescription { switch (self.type) { + case GROUP: return [NSString stringWithFormat:@"%@:", self.name]; + case FEED: return [NSString stringWithFormat:@"%@ (%@)", self.name, self.feed.meta.url]; case SEPARATOR: return @"-------------"; - case GROUP: return [NSString stringWithFormat:@"%@", self.name]; - case FEED: - return [NSString stringWithFormat:@"%@ (%@) - %@", self.name, self.feed.meta.url, [self refreshString]]; } } -/// @return Formatted string for update interval ( e.g., @c 30m or @c 12h ) -- (nonnull NSString*)refreshString { - if (self.type == FEED) { - int32_t refresh = self.feed.meta.refresh; - if (refresh <= 0) - return @"∞"; // ∞ ƒ Ø - return [NSDate stringForInterval:refresh rounded:NO]; - } - return @""; -} - @end diff --git a/baRSS/Core Data/FeedMeta+Ext.m b/baRSS/Core Data/FeedMeta+Ext.m index c4838c4..61e7da4 100644 --- a/baRSS/Core Data/FeedMeta+Ext.m +++ b/baRSS/Core Data/FeedMeta+Ext.m @@ -33,7 +33,6 @@ if (self.errorCount < 0) self.errorCount = 0; int16_t n = self.errorCount + 1; // always increment errorCount (can be used to indicate bad feeds) - // TODO: remove logging #ifdef DEBUG NSLog(@"ERROR: Feed download failed: %@ (errorCount: %d)", self.url, n); #endif diff --git a/baRSS/Helper/NSDate+Ext.h b/baRSS/Helper/NSDate+Ext.h index d35557d..498da6a 100644 --- a/baRSS/Helper/NSDate+Ext.h +++ b/baRSS/Helper/NSDate+Ext.h @@ -39,8 +39,10 @@ typedef NS_ENUM(int32_t, TimeUnitType) { @interface NSDate (Interval) -+ (nonnull NSString*)stringForInterval:(Interval)intv rounded:(BOOL)flag; -+ (TimeUnitType)unitForInterval:(Interval)intv rounded:(BOOL)flag; ++ (nullable NSString*)intStringForInterval:(Interval)intv; ++ (nonnull NSString*)floatStringForInterval:(Interval)intv; ++ (nullable NSString*)stringForRemainingTime:(NSDate*)other; ++ (Interval)floatToIntInterval:(Interval)intv; @end diff --git a/baRSS/Helper/NSDate+Ext.m b/baRSS/Helper/NSDate+Ext.m index ddf90da..65b482d 100644 --- a/baRSS/Helper/NSDate+Ext.m +++ b/baRSS/Helper/NSDate+Ext.m @@ -24,8 +24,6 @@ #import -static const char _shortnames[] = {'y','w','d','h','m','s'}; -static const char *_names[] = {"Years", "Weeks", "Days", "Hours", "Minutes", "Seconds"}; static const TimeUnitType _values[] = { TimeUnitYears, TimeUnitWeeks, @@ -55,61 +53,58 @@ static const TimeUnitType _values[] = { @implementation NSDate (Interval) -/// If @c flag @c = @c YES, print @c 1.1f float string with single char unit: e.g., 3.3m, 1.7h. -+ (nonnull NSString*)stringForInterval:(Interval)intv rounded:(BOOL)flag { - if (flag) { - unsigned short i = [self floatUnitIndexForInterval:abs(intv)]; - return [NSString stringWithFormat:@"%1.1f%c", intv / (float)_values[i], _shortnames[i]]; +/// Short interval formatter string (e.g., '30 min', '2 hrs') ++ (nullable NSString*)intStringForInterval:(Interval)intv { + TimeUnitType unit = [self unitForInterval:intv]; + Interval num = intv / unit; + NSDateComponents *dc = [[NSDateComponents alloc] init]; + switch (unit) { + case TimeUnitSeconds: dc.second = num; break; + case TimeUnitMinutes: dc.minute = num; break; + case TimeUnitHours: dc.hour = num; break; + case TimeUnitDays: dc.day = num; break; + case TimeUnitWeeks: dc.weekOfMonth = num; break; + case TimeUnitYears: dc.year = num; break; } - unsigned short i = [self exactUnitIndexForInterval:abs(intv)]; - return [NSString stringWithFormat:@"%d%c", intv / _values[i], _shortnames[i]]; + return [NSDateComponentsFormatter localizedStringFromDateComponents:dc unitsStyle:NSDateComponentsFormatterUnitsStyleShort]; } -/// @return Highest non-zero unit ( @c flag=YES ). Or highest integer-dividable unit ( @c flag=NO ). -+ (TimeUnitType)unitForInterval:(Interval)intv rounded:(BOOL)flag { - if (flag) { - return _values[[self floatUnitIndexForInterval:abs(intv)]]; - } - return _values[[self exactUnitIndexForInterval:abs(intv)]]; +/// Print @c 1.1f float string with single char unit: e.g., 3.3m, 1.7h. ++ (nonnull NSString*)floatStringForInterval:(Interval)intv { + unsigned short i = [self floatUnitIndexForInterval:abs(intv)]; + return [NSString stringWithFormat:@"%1.1f%c", intv / (float)_values[i], "ywdhms"[i]]; } -/// @return Highest unit type that allows integer division. E.g., '61 minutes'. -+ (unsigned short)exactUnitIndexForInterval:(Interval)intv { - for (unsigned short i = 0; i < 5; i++) - if (intv % _values[i] == 0) return i; - return 5; // seconds +/// Short interval formatter string for remaining time until @c other date ++ (nullable NSString*)stringForRemainingTime:(NSDate*)other { + NSDateComponentsFormatter *formatter = [[NSDateComponentsFormatter alloc] init]; + formatter.unitsStyle = NSDateComponentsFormatterUnitsStyleShort; // e.g., '30 min' + formatter.maximumUnitCount = 1; + return [formatter stringFromTimeInterval: other.timeIntervalSinceNow]; +} + +/// Round uneven intervals to highest unit interval. E.g., @c 1:40–>2:00 or @c 1:03–>1:00 ++ (Interval)floatToIntInterval:(Interval)intv { + TimeUnitType unit = _values[[self floatUnitIndexForInterval:abs(intv)]]; + return (Interval)(roundf((float)intv / unit) * unit); +} + +/// @return Highest integer-dividable unit. E.g., '61 minutes' ++ (TimeUnitType)unitForInterval:(Interval)intv { + if (intv == 0) return TimeUnitMinutes; // fallback to 0 minutes + for (unsigned short i = 0; i < 5; i++) // try: years -> minutes + if (intv % _values[i] == 0) return _values[i]; + return TimeUnitSeconds; } /// @return Highest non-zero unit type. Can be used with fractions e.g., '1.1 hours'. + (unsigned short)floatUnitIndexForInterval:(Interval)intv { + if (intv == 0) return 4; // fallback to 0 minutes for (unsigned short i = 0; i < 5; i++) if (intv > _values[i]) return i; return 5; // seconds } -/* NOT USED -/// Convert any unit to the next smaller one. Unit does not have to be exact. -+ (TimeUnitType)smallerUnit:(TimeUnitType)unit { - if (unit <= TimeUnitHours) return TimeUnitSeconds; - if (unit <= TimeUnitDays) return TimeUnitMinutes; // > hours - if (unit <= TimeUnitWeeks) return TimeUnitHours; // > days - if (unit <= TimeUnitYears) return TimeUnitDays; // > weeks - return TimeUnitWeeks; // > years -} -/// @return Formatted string from @c timeIntervalSinceNow. -- (nonnull NSString*)intervalStringWithDecimal:(BOOL)flag { - return [NSDate stringForInterval:(Interval)[self timeIntervalSinceNow] rounded:flag]; -} - -/// @return Highest non-zero unit ( @c flag=YES ). Or highest integer-dividable unit ( @c flag=NO ). -- (TimeUnitType)unitWithDecimal:(BOOL)flag { - Interval absIntv = abs((Interval)[self timeIntervalSinceNow]); - if (flag) { - return _values[ [NSDate floatUnitIndexForInterval:absIntv] ]; - } - return _values[ [NSDate exactUnitIndexForInterval:absIntv] ]; -} -*/ @end @@ -122,7 +117,7 @@ static const TimeUnitType _values[] = { /// Configure both @c NSControl elements based on the provided interval @c intv. + (void)setInterval:(Interval)intv forPopup:(NSPopUpButton*)popup andField:(NSTextField*)field animate:(BOOL)flag { - TimeUnitType unit = [self unitForInterval:intv rounded:NO]; + TimeUnitType unit = [self unitForInterval:intv]; int num = (int)(intv / unit); if (flag && popup.selectedTag != unit) [self animateControlSize:popup]; if (flag && field.intValue != num) [self animateControlSize:field]; @@ -133,11 +128,12 @@ static const TimeUnitType _values[] = { /// Insert all @c TimeUnitType items into popup button. Save unit value into @c tag attribute. + (void)populateUnitsMenu:(NSPopUpButton*)popup selected:(TimeUnitType)unit { [popup removeAllItems]; - for (NSUInteger i = 0; i < 6; i++) { - [popup addItemWithTitle:[NSString stringWithUTF8String:_names[i]]]; - NSMenuItem *item = popup.lastItem; - [item setKeyEquivalent:[[NSString stringWithFormat:@"%c", _shortnames[i]] uppercaseString]]; - item.tag = _values[i]; + [popup addItemsWithTitles:@[NSLocalizedString(@"Years", nil), NSLocalizedString(@"Weeks", nil), + NSLocalizedString(@"Days", nil), NSLocalizedString(@"Hours", nil), + NSLocalizedString(@"Minutes", nil), NSLocalizedString(@"Seconds", nil)]]; + for (int i = 0; i < 6; i++) { + [popup itemAtIndex:i].tag = _values[i]; + [popup itemAtIndex:i].keyEquivalent = [NSString stringWithFormat:@"%d", i+1]; // Cmd+1 .. Cmd+6 } [popup selectItemWithTag:unit]; } diff --git a/baRSS/Info.plist b/baRSS/Info.plist index e77ac29..902385f 100644 --- a/baRSS/Info.plist +++ b/baRSS/Info.plist @@ -32,7 +32,7 @@ CFBundleVersion - 7749 + 8018 LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) LSUIElement diff --git a/baRSS/Preferences/Feeds Tab/RefreshStatisticsView.m b/baRSS/Preferences/Feeds Tab/RefreshStatisticsView.m index 248d91a..18d244a 100644 --- a/baRSS/Preferences/Feeds Tab/RefreshStatisticsView.m +++ b/baRSS/Preferences/Feeds Tab/RefreshStatisticsView.m @@ -87,10 +87,10 @@ NS_INLINE NSTextField* GrayLabel(NSString *text) { /// Inline button with tag equal to refresh interval. @c 16px height. - (NSButton*)createInlineButton:(NSNumber*)num callback:(nullable id)callback { - NSButton *button = [NSView inlineButton:[NSDate stringForInterval:num.intValue rounded:YES]]; - TimeUnitType unit = [NSDate unitForInterval:num.intValue rounded:YES]; - button.tag = (NSInteger)(roundf(num.floatValue / unit) * unit); // rounded interval - // TODO: accessibility title: readable interval string + NSButton *button = [NSView inlineButton: [NSDate floatStringForInterval:num.intValue]]; + Interval intv = [NSDate floatToIntInterval:num.intValue]; // rounded to highest unit + button.accessibilityTitle = [NSDate intStringForInterval:intv]; + button.tag = (NSInteger)intv; if (callback) { [button action:@selector(refreshIntervalButtonClicked:) target:callback]; } else { diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeeds.m b/baRSS/Preferences/Feeds Tab/SettingsFeeds.m index 7225364..9c04421 100644 --- a/baRSS/Preferences/Feeds Tab/SettingsFeeds.m +++ b/baRSS/Preferences/Feeds Tab/SettingsFeeds.m @@ -29,6 +29,7 @@ #import "OpmlExport.h" #import "FeedDownload.h" #import "SettingsFeedsView.h" +#import "NSDate+Ext.h" @interface SettingsFeeds () @property (strong) SettingsFeedsView *view; // override super @@ -37,7 +38,6 @@ @property (strong) NSUndoManager *undoManager; @property (strong) NSTimer *timerStatusInfo; -@property (strong) NSDateComponentsFormatter *intervalFormatter; @end @implementation SettingsFeeds @@ -94,9 +94,6 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; /// Initialize status info timer - (void)viewWillAppear { [self.dataStore rearrangeObjects]; // needed to scroll outline view to top (if prefs open on another tab) - self.intervalFormatter = [[NSDateComponentsFormatter alloc] init]; - self.intervalFormatter.unitsStyle = NSDateComponentsFormatterUnitsStyleShort; // e.g., '30 min' - self.intervalFormatter.maximumUnitCount = 1; self.timerStatusInfo = [NSTimer timerWithTimeInterval:NSTimeIntervalSince1970 target:self selector:@selector(keepTimerRunning) userInfo:nil repeats:YES]; [[NSRunLoop mainRunLoop] addTimer:self.timerStatusInfo forMode:NSRunLoopCommonModes]; // start spinner if update is in progress when preferences open @@ -108,7 +105,6 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; // in viewWillDisappear otherwise dealloc will not be called [self.timerStatusInfo invalidate]; self.timerStatusInfo = nil; - self.intervalFormatter = nil; } /// Callback method to update status info. Will be called more often when interval is getting shorter. @@ -120,14 +116,11 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; self.view.status.stringValue = @""; return; } - if (nextFire > 60) { // update 1/min - nextFire = fmod(nextFire, 60); // next update will align with minute - } else { - nextFire = 1; // update 1/sec - } - NSString *str = [self.intervalFormatter stringFromTimeInterval: date.timeIntervalSinceNow]; - self.view.status.stringValue = [NSString stringWithFormat:NSLocalizedString(@"Next update in %@", nil), str]; - [self.timerStatusInfo setFireDate:[NSDate dateWithTimeIntervalSinceNow: nextFire]]; + self.view.status.stringValue = [NSString stringWithFormat:NSLocalizedString(@"Next update in %@", nil), + [NSDate stringForRemainingTime:date]]; + // Next update is aligned with minute (fmod) else update 1/sec + NSDate *nextUpdate = [NSDate dateWithTimeIntervalSinceNow: (nextFire > 60 ? fmod(nextFire, 60) : 1)]; + [self.timerStatusInfo setFireDate:nextUpdate]; } } diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeedsView.m b/baRSS/Preferences/Feeds Tab/SettingsFeedsView.m index 3b7e850..bc422fd 100644 --- a/baRSS/Preferences/Feeds Tab/SettingsFeedsView.m +++ b/baRSS/Preferences/Feeds Tab/SettingsFeedsView.m @@ -93,7 +93,7 @@ NSTableColumn *colRefresh = [[NSTableColumn alloc] initWithIdentifier:CustomCellRefresh]; colRefresh.title = NSLocalizedString(@"Refresh", nil); - colRefresh.width = 50; + colRefresh.width = 60; colRefresh.resizingMask = NSTableColumnNoResizing; [outline addTableColumn:colRefresh]; @@ -224,15 +224,19 @@ NSUserInterfaceItemIdentifier const CustomCellRefresh = @"RefreshColumnCell"; self = [super initWithFrame:frameRect]; self.identifier = CustomCellRefresh; self.textField = [[[[NSView label:@""] textRight] placeIn:self x:0 yTop:0] sizeToRight:0]; - self.textField.accessibilityLabel = NSLocalizedString(@"Refresh interval", nil); + self.textField.accessibilityTitle = @" "; // otherwise groups and separators will say 'text' return self; } - (void)setObjectValue:(FeedGroup*)fg { - NSString *str = [fg refreshString]; + NSString *str = @""; + if (fg.type == FEED) { + int32_t refresh = fg.feed.meta.refresh; + str = (refresh <= 0 ? @"∞" : [NSDate intStringForInterval:refresh]); // ∞ ƒ Ø + } self.textField.objectValue = str; - // TODO: accessibility title: readable interval string self.textField.textColor = (str.length > 1 ? [NSColor controlTextColor] : [NSColor disabledControlTextColor]); + self.textField.accessibilityLabel = (str.length > 1 ? NSLocalizedString(@"Refresh interval", nil) : nil); } @end