Auto update feeds. Menu rebuild so far only after close.
This commit is contained in:
19
README.md
19
README.md
@@ -35,7 +35,7 @@ ToDo
|
||||
- [ ] Tick mark feed items based on prefs
|
||||
- [ ] Open a few links (# editable)
|
||||
- [ ] Performance: Update menu partially
|
||||
- [ ] Start on login
|
||||
- [x] Start on login
|
||||
- [x] Make it system default application
|
||||
- [ ] Display license info (e.g., RSXML)
|
||||
- [ ] Short article names
|
||||
@@ -47,14 +47,16 @@ ToDo
|
||||
- [ ] Status menu
|
||||
- [ ] Update menu header after mark (un)read
|
||||
- [ ] Pause updates functionality
|
||||
- [ ] Update all feeds functionality
|
||||
- [x] Update all feeds functionality
|
||||
- [ ] Hold only relevant information in memory
|
||||
|
||||
|
||||
- [ ] Edit feed
|
||||
- [ ] Show statistics
|
||||
- [ ] How often gets the feed updated (min, max, avg)
|
||||
- [ ] Automatically choose best interval?
|
||||
- [ ] Auto fix 301 Redirect or ask user
|
||||
- [ ] Show time of next update
|
||||
- [x] Auto fix 301 Redirect or ask user
|
||||
- [ ] Make `feed://` URLs clickable
|
||||
- [ ] Feeds with authentication
|
||||
- [ ] Show proper feed icon
|
||||
@@ -64,15 +66,18 @@ ToDo
|
||||
- [ ] Other
|
||||
- [ ] App Icon
|
||||
- [ ] Translate text to different languages
|
||||
- [ ] Automatically update feeds with chosen interval
|
||||
- [ ] Reuse ETag and Modification date
|
||||
- [ ] Append only new items, keep sorting
|
||||
- [ ] Delete old ones eventually
|
||||
- [x] Automatically update feeds with chosen interval
|
||||
- [x] Reuse ETag and Modification date
|
||||
- ~~[ ] Append only new items, keep sorting~~
|
||||
- [x] Delete old ones eventually
|
||||
- [x] Pause on internet connection lost
|
||||
- [ ] Download with ephemeral url session?
|
||||
- [ ] Purge cache
|
||||
- [ ] Manually or automatically
|
||||
- [ ] Add something to restore a broken state
|
||||
- [ ] Code Documentation (mostly methods)
|
||||
- [ ] Add Sandboxing
|
||||
- [ ] Disable Startup checkbox (or other workaround)
|
||||
|
||||
|
||||
- [ ] Additional features
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="14135" systemVersion="17G65" minimumToolsVersion="Automatic" sourceLanguage="Objective-C" userDefinedModelVersionIdentifier="v1">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="14315.18" systemVersion="17G65" minimumToolsVersion="Automatic" sourceLanguage="Objective-C" userDefinedModelVersionIdentifier="v1">
|
||||
<entity name="Feed" representedClassName="Feed" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="httpEtag" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<attribute name="httpModified" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<attribute name="icon" optional="YES" attributeType="Binary" customClassName="NSImage" syncable="YES"/>
|
||||
<attribute name="link" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<attribute name="subtitle" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<attribute name="title" optional="YES" attributeType="String" syncable="YES"/>
|
||||
@@ -11,15 +8,17 @@
|
||||
<relationship name="items" optional="YES" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="FeedItem" inverseName="feed" inverseEntity="FeedItem" syncable="YES"/>
|
||||
</entity>
|
||||
<entity name="FeedConfig" representedClassName="FeedConfig" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="errorCount" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
|
||||
<attribute name="name" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<attribute name="refreshNum" optional="YES" attributeType="Integer 32" usesScalarValueType="YES" syncable="YES"/>
|
||||
<attribute name="refreshUnit" optional="YES" attributeType="Integer 16" usesScalarValueType="YES" customClassName="NSUInteger" syncable="YES"/>
|
||||
<attribute name="refreshNum" optional="YES" attributeType="Integer 32" defaultValueString="-1" usesScalarValueType="YES" syncable="YES"/>
|
||||
<attribute name="refreshUnit" optional="YES" attributeType="Integer 16" defaultValueString="-1" usesScalarValueType="YES" customClassName="NSUInteger" syncable="YES"/>
|
||||
<attribute name="scheduled" optional="YES" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
|
||||
<attribute name="sortIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
|
||||
<attribute name="type" optional="YES" attributeType="Integer 16" usesScalarValueType="YES" syncable="YES"/>
|
||||
<attribute name="type" optional="YES" attributeType="Integer 16" defaultValueString="-1" usesScalarValueType="YES" syncable="YES"/>
|
||||
<attribute name="url" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<relationship name="children" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="FeedConfig" inverseName="parent" inverseEntity="FeedConfig" syncable="YES"/>
|
||||
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Feed" inverseName="config" inverseEntity="Feed" syncable="YES"/>
|
||||
<relationship name="meta" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="FeedMeta" inverseName="config" inverseEntity="FeedMeta" syncable="YES"/>
|
||||
<relationship name="parent" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="FeedConfig" inverseName="children" inverseEntity="FeedConfig" syncable="YES"/>
|
||||
</entity>
|
||||
<entity name="FeedItem" representedClassName="FeedItem" syncable="YES" codeGenerationType="class">
|
||||
@@ -33,9 +32,16 @@
|
||||
<attribute name="unread" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
|
||||
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="items" inverseEntity="Feed" syncable="YES"/>
|
||||
</entity>
|
||||
<entity name="FeedMeta" representedClassName="FeedMeta" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="httpEtag" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<attribute name="httpModified" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<attribute name="icon" optional="YES" attributeType="Binary" customClassName="NSImage" syncable="YES"/>
|
||||
<relationship name="config" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="FeedConfig" inverseName="meta" inverseEntity="FeedConfig" syncable="YES"/>
|
||||
</entity>
|
||||
<elements>
|
||||
<element name="Feed" positionX="-209" positionY="-3" width="128" height="165"/>
|
||||
<element name="FeedConfig" positionX="-20" positionY="-126" width="128" height="195"/>
|
||||
<element name="FeedItem" positionX="-20" positionY="81" width="128" height="180"/>
|
||||
<element name="Feed" positionX="-229.09375" positionY="-2.30859375" width="128" height="120"/>
|
||||
<element name="FeedConfig" positionX="-441.87109375" positionY="-47.390625" width="128" height="225"/>
|
||||
<element name="FeedItem" positionX="-28.140625" positionY="-17.359375" width="128" height="180"/>
|
||||
<element name="FeedMeta" positionX="-234" positionY="72" width="128" height="105"/>
|
||||
</elements>
|
||||
</model>
|
||||
@@ -24,5 +24,9 @@
|
||||
#import <RSXML/RSXML.h>
|
||||
|
||||
@interface FeedDownload : NSObject
|
||||
+ (void)getFeed:(NSString *)url block:(void(^)(RSParsedFeed *feed, NSError* error, NSHTTPURLResponse* response))block;
|
||||
+ (void)newFeed:(NSString *)url block:(void(^)(RSParsedFeed *feed, NSError* error, NSHTTPURLResponse* response))block;
|
||||
+ (void)registerNetworkChangeNotification;
|
||||
+ (void)unregisterNetworkChangeNotification;
|
||||
+ (BOOL)isNetworkReachable;
|
||||
+ (void)scheduleNextUpdate:(BOOL)forceUpdate;
|
||||
@end
|
||||
|
||||
@@ -21,21 +21,37 @@
|
||||
// SOFTWARE.
|
||||
|
||||
#import "FeedDownload.h"
|
||||
#import "StoreCoordinator.h"
|
||||
#import <SystemConfiguration/SystemConfiguration.h>
|
||||
|
||||
static SCNetworkReachabilityRef _reachability = NULL;
|
||||
static BOOL _isReachable = NO;
|
||||
|
||||
|
||||
@implementation FeedDownload
|
||||
|
||||
+ (void)getFeed:(NSString *)url block:(void(^)(RSParsedFeed *feed, NSError* error, NSHTTPURLResponse* response))block {
|
||||
+ (NSMutableURLRequest*)newRequestURL:(NSString*)url {
|
||||
NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]];
|
||||
req.timeoutInterval = 30;
|
||||
req.cachePolicy = NSURLRequestReloadIgnoringCacheData;
|
||||
// [req setValue:@"Mon, 10 Sep 2018 10:32:19 GMT" forHTTPHeaderField:@"If-Modified-Since"];
|
||||
// [req setValue:@"wII2pETT9EGmlqyCHBFJpm25/7w" forHTTPHeaderField:@"If-None-Match"]; // ETag
|
||||
[[[NSURLSession sharedSession] dataTaskWithRequest:req completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
|
||||
return req;
|
||||
}
|
||||
|
||||
+ (NSURLRequest*)newRequest:(FeedConfig*)config {
|
||||
NSMutableURLRequest *req = [self newRequestURL:config.url];
|
||||
NSString* etag = [config.meta.httpEtag stringByReplacingOccurrencesOfString:@"-gzip" withString:@""];
|
||||
if (config.meta.httpModified.length > 0)
|
||||
[req setValue:config.meta.httpModified forHTTPHeaderField:@"If-Modified-Since"];
|
||||
if (etag.length > 0)
|
||||
[req setValue:etag forHTTPHeaderField:@"If-None-Match"]; // ETag
|
||||
return req;
|
||||
}
|
||||
|
||||
+ (void)newFeed:(NSString *)url block:(void(^)(RSParsedFeed *feed, NSError* error, NSHTTPURLResponse* response))block {
|
||||
[[[NSURLSession sharedSession] dataTaskWithRequest:[self newRequestURL:url] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
|
||||
NSHTTPURLResponse* httpResponse = (NSHTTPURLResponse*)response;
|
||||
// NSString* etag = [httpResponse allHeaderFields][@"Etag"];
|
||||
// if (etag.length > 5 && [[etag substringFromIndex:etag.length - 5] isEqualToString:@"-gzip"]) {
|
||||
// etag = [etag substringToIndex:etag.length - 5];
|
||||
// }
|
||||
if (error || [httpResponse statusCode] == 304) {
|
||||
block(nil, error, httpResponse);
|
||||
return;
|
||||
@@ -47,4 +63,154 @@
|
||||
}] resume];
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Update existing feeds -
|
||||
|
||||
|
||||
+ (void)scheduleNextUpdate:(BOOL)forceUpdate {
|
||||
static NSTimer *_updateTimer;
|
||||
@synchronized (_updateTimer) {
|
||||
if (_updateTimer) {
|
||||
[_updateTimer invalidate];
|
||||
_updateTimer = nil;
|
||||
}
|
||||
}
|
||||
if (!_isReachable) return; // cancel timer entirely (will be restarted once connection exists)
|
||||
NSDate *nextTime = [NSDate dateWithTimeIntervalSinceNow:0.2];
|
||||
if (!forceUpdate) {
|
||||
nextTime = [StoreCoordinator nextScheduledUpdate];
|
||||
if (!nextTime || [nextTime timeIntervalSinceNow] < 0) { // mostly, if app was closed for a long time
|
||||
nextTime = [NSDate dateWithTimeIntervalSinceNow:2]; // TODO: retry in 2 sec?
|
||||
}
|
||||
}
|
||||
NSTimeInterval tolerance = [nextTime timeIntervalSinceNow] * 0.15;
|
||||
_updateTimer = [NSTimer timerWithTimeInterval:0 target:[self class] selector:@selector(scheduledUpdateTimer:) userInfo:@(forceUpdate) repeats:NO];
|
||||
_updateTimer.fireDate = nextTime;
|
||||
_updateTimer.tolerance = (tolerance < 1 ? 1 : tolerance); // at least 1 sec
|
||||
[[NSRunLoop mainRunLoop] addTimer:_updateTimer forMode:NSRunLoopCommonModes];
|
||||
}
|
||||
|
||||
+ (void)scheduledUpdateTimer:(NSTimer*)timer {
|
||||
NSLog(@"fired");
|
||||
BOOL forceAll = [timer.userInfo boolValue];
|
||||
// TODO: check internet connection
|
||||
// TODO: disable menu item 'update all' during update
|
||||
NSArray<FeedConfig*> *list = [StoreCoordinator getListOfFeedsThatNeedUpdate:forceAll];
|
||||
if (list.count == 0) {
|
||||
NSLog(@"ERROR: Something went wrong, timer fired too early.");
|
||||
// thechnically should never happen, anyway we need to reset the timer
|
||||
[self scheduleNextUpdate:NO]; // NO, since forceAll will get ALL items and shouldn't be 0
|
||||
return; // nothing to do here
|
||||
}
|
||||
NSUndoManager *um = list.firstObject.managedObjectContext.undoManager;
|
||||
[um beginUndoGrouping];
|
||||
dispatch_group_t group = dispatch_group_create();
|
||||
for (FeedConfig *c in list) {
|
||||
[self downloadFeedForConfig:c group:group];
|
||||
}
|
||||
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
|
||||
[um endUndoGrouping];
|
||||
[self scheduleNextUpdate:NO]; // after forced update, continue regular cycle
|
||||
});
|
||||
}
|
||||
|
||||
+ (void)downloadFeedForConfig:(FeedConfig*)config group:(dispatch_group_t)group {
|
||||
if (!_isReachable) return;
|
||||
dispatch_group_enter(group);
|
||||
[[[NSURLSession sharedSession] dataTaskWithRequest:[self newRequest:config] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
|
||||
[config.managedObjectContext.undoManager beginUndoGrouping];
|
||||
if (error) {
|
||||
int16_t n = config.errorCount + 1;
|
||||
config.errorCount = (n < 1 ? 1 : (n > 19 ? 19 : n)); // between: 2 sec and 6 days
|
||||
NSTimeInterval retryWaitTime = pow(2, config.errorCount); // 2^n seconds
|
||||
config.scheduled = [NSDate dateWithTimeIntervalSinceNow:retryWaitTime];
|
||||
// TODO: remove logging
|
||||
NSLog(@"Error loading: %@ (%d)", response.URL, config.errorCount);
|
||||
} else {
|
||||
config.errorCount = 0; // reset counter
|
||||
[self downloadSuccessful:data forFeed:config response:(NSHTTPURLResponse*)response];
|
||||
}
|
||||
[config.managedObjectContext.undoManager endUndoGrouping];
|
||||
dispatch_group_leave(group);
|
||||
}] resume];
|
||||
}
|
||||
|
||||
+ (void)downloadSuccessful:(NSData*)data forFeed:(FeedConfig*)config response:(NSHTTPURLResponse*)http {
|
||||
if ([http statusCode] != 304) {
|
||||
// should be fine to call synchronous since dataTask is already in the background (always? proof?)
|
||||
RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:config.url];
|
||||
RSParsedFeed *parsed = RSParseFeedSync(xml, NULL);
|
||||
if (parsed) {
|
||||
// TODO: add support for media player?
|
||||
// <enclosure url="https://url.mp3" length="63274022" type="audio/mpeg" />
|
||||
[StoreCoordinator overwriteConfig:config withFeed:parsed];
|
||||
}
|
||||
}
|
||||
config.meta.httpModified = [http allHeaderFields][@"Date"]; // @"Expires", @"Last-Modified"
|
||||
config.meta.httpEtag = [http allHeaderFields][@"Etag"];
|
||||
// Don't update redirected url since it happened in the background; User may not recognize url
|
||||
[config calculateAndSetScheduled];
|
||||
[config mergeChangesAndSave];
|
||||
[[NSNotificationCenter defaultCenter] postNotificationName:@"baRSS-notification-feed-updated" object:config];
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Network Connection -
|
||||
|
||||
|
||||
+ (BOOL)isNetworkReachable { return _isReachable; }
|
||||
|
||||
+ (void)registerNetworkChangeNotification {
|
||||
// https://stackoverflow.com/questions/11240196/notification-when-wifi-connected-os-x
|
||||
if (_reachability != NULL) return;
|
||||
_reachability = SCNetworkReachabilityCreateWithName(NULL, "1.1.1.1");
|
||||
if (_reachability == NULL) return;
|
||||
// If reachability information is available now, we don't get a callback later
|
||||
SCNetworkConnectionFlags flags;
|
||||
if (SCNetworkReachabilityGetFlags(_reachability, &flags))
|
||||
networkReachabilityCallback(_reachability, flags, NULL);
|
||||
if (!SCNetworkReachabilitySetCallback(_reachability, networkReachabilityCallback, NULL) ||
|
||||
!SCNetworkReachabilityScheduleWithRunLoop(_reachability, [[NSRunLoop currentRunLoop] getCFRunLoop], kCFRunLoopCommonModes))
|
||||
{
|
||||
CFRelease(_reachability);
|
||||
_reachability = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
+ (void)unregisterNetworkChangeNotification {
|
||||
if (_reachability != NULL) {
|
||||
SCNetworkReachabilitySetCallback(_reachability, nil, nil);
|
||||
SCNetworkReachabilitySetDispatchQueue(_reachability, nil);
|
||||
CFRelease(_reachability);
|
||||
_reachability = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
static void networkReachabilityCallback(SCNetworkReachabilityRef target, SCNetworkConnectionFlags flags, void *object) {
|
||||
if (_reachability == NULL)
|
||||
return;
|
||||
_isReachable = [FeedDownload hasConnectivity:flags];
|
||||
[[NSNotificationCenter defaultCenter] postNotificationName:@"baRSS-notification-network-status-change"
|
||||
object:[NSNumber numberWithBool:_isReachable]];
|
||||
if (_isReachable) {
|
||||
NSLog(@"reachable");
|
||||
} else {
|
||||
NSLog(@"not reachable");
|
||||
}
|
||||
// schedule regardless of state (if not reachable timer will be canceled)
|
||||
[FeedDownload scheduleNextUpdate:NO];
|
||||
}
|
||||
|
||||
+ (BOOL)hasConnectivity:(SCNetworkReachabilityFlags)flags {
|
||||
if ((flags & kSCNetworkReachabilityFlagsReachable) == 0)
|
||||
return NO;
|
||||
if ((flags & kSCNetworkReachabilityFlagsConnectionRequired) == 0)
|
||||
return YES;
|
||||
if ((flags & kSCNetworkReachabilityFlagsInterventionRequired) == 0 &&
|
||||
((flags & kSCNetworkReachabilityFlagsConnectionOnDemand) != 0 ||
|
||||
(flags & kSCNetworkReachabilityFlagsConnectionOnTraffic) != 0))
|
||||
return YES; // no-intervention AND ( on-demand OR on-traffic )
|
||||
return NO;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -42,8 +42,11 @@ typedef BOOL (^FeedConfigRecursiveItemsBlock) (FeedConfig *parent, FeedItem *ite
|
||||
|
||||
@property (getter=typ, setter=setTyp:) FeedConfigType typ;
|
||||
@property (readonly) NSArray<FeedConfig*> *sortedChildren;
|
||||
@property (readonly) NSIndexPath *indexPath;
|
||||
|
||||
- (BOOL)descendantFeedItems:(FeedConfigRecursiveItemsBlock)block;
|
||||
- (void)calculateAndSetScheduled;
|
||||
- (void)mergeChangesAndSave;
|
||||
- (NSString*)readableRefreshString;
|
||||
- (NSString*)readableDescription;
|
||||
@end
|
||||
|
||||
@@ -40,6 +40,12 @@
|
||||
return [self.children sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]]];
|
||||
}
|
||||
|
||||
- (NSIndexPath *)indexPath {
|
||||
if (self.parent == nil)
|
||||
return [NSIndexPath indexPathWithIndex:(NSUInteger)self.sortIndex];
|
||||
return [self.parent.indexPath indexPathByAddingIndex:(NSUInteger)self.sortIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
Iterate over all descendant @c FeedItems in sub groups
|
||||
|
||||
@@ -61,6 +67,25 @@
|
||||
return YES;
|
||||
}
|
||||
|
||||
/// @return Time interval respecting the selected unit. E.g., returns @c 180 for @c '3m'
|
||||
- (NSTimeInterval)timeInterval {
|
||||
static const int unit[] = {1, 60, 3600, 86400, 604800}; // smhdw
|
||||
return self.refreshNum * unit[self.refreshUnit % 5];
|
||||
}
|
||||
|
||||
/// Calculate date from @c refreshNum and @c refreshUnit and set as next scheduled feed update.
|
||||
- (void)calculateAndSetScheduled {
|
||||
self.scheduled = [[NSDate date] dateByAddingTimeInterval:[self timeInterval]];
|
||||
}
|
||||
|
||||
/// Update item with @c mergeChanges:YES and save the context
|
||||
- (void)mergeChangesAndSave {
|
||||
[self.managedObjectContext performBlockAndWait:^{
|
||||
[self.managedObjectContext refreshObject:self mergeChanges:YES];
|
||||
[self.managedObjectContext save:nil];
|
||||
}];
|
||||
}
|
||||
|
||||
/// @return Formatted string for update interval ( e.g., @c 30m or @c 12h )
|
||||
- (NSString*)readableRefreshString {
|
||||
return [NSString stringWithFormat:@"%d%c", self.refreshNum, [@"smhdw" characterAtIndex:self.refreshUnit % 5]];
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
@property (weak) IBOutlet NSPopover *warningPopover;
|
||||
|
||||
@property (copy) NSString *previousURL;
|
||||
@property (copy) NSString *httpDate;
|
||||
@property (copy) NSString *httpEtag;
|
||||
@property (strong) NSError *feedError;
|
||||
@property (strong) RSParsedFeed *feedResult;
|
||||
|
||||
@@ -107,15 +109,19 @@
|
||||
item.refreshUnit = (int16_t)self.refreshUnit.indexOfSelectedItem;
|
||||
|
||||
if (self.shouldDeletePrevArticles) {
|
||||
[StoreCoordinator overwriteConfig:item withFeed:self.feedResult];
|
||||
[item.managedObjectContext performBlockAndWait:^{
|
||||
if (item.feed)
|
||||
[item.managedObjectContext deleteObject:(NSManagedObject*)item.feed];
|
||||
if (self.feedResult)
|
||||
item.feed = [StoreCoordinator createFeedFrom:self.feedResult inContext:item.managedObjectContext];
|
||||
// TODO: move to separate function and add icon download
|
||||
if (!item.meta) {
|
||||
item.meta = [[FeedMeta alloc] initWithEntity:FeedMeta.entity insertIntoManagedObjectContext:item.managedObjectContext];
|
||||
}
|
||||
item.meta.httpEtag = self.httpEtag;
|
||||
item.meta.httpModified = self.httpDate;
|
||||
}];
|
||||
}
|
||||
if ([item.managedObjectContext hasChanges]) {
|
||||
self.objectIsModified = YES;
|
||||
[item calculateAndSetScheduled];
|
||||
[item.managedObjectContext performBlockAndWait:^{
|
||||
[item.managedObjectContext refreshObject:item mergeChanges:YES];
|
||||
}];
|
||||
@@ -146,11 +152,16 @@
|
||||
self.feedError = nil;
|
||||
[self.spinnerURL startAnimation:nil];
|
||||
[self.spinnerName startAnimation:nil];
|
||||
[FeedDownload getFeed:self.previousURL block:^(RSParsedFeed *result, NSError *error, NSHTTPURLResponse* response) {
|
||||
[FeedDownload newFeed:self.previousURL block:^(RSParsedFeed *result, NSError *error, NSHTTPURLResponse* response) {
|
||||
self.feedResult = result;
|
||||
// [httpResponse allHeaderFields][@"Date"]; // @"Expires", @"Last-Modified"
|
||||
// [httpResponse allHeaderFields][@"Etag"];
|
||||
self.httpDate = [response allHeaderFields][@"Date"]; // @"Expires", @"Last-Modified"
|
||||
self.httpEtag = [response allHeaderFields][@"Etag"];
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (response && ![response.URL.absoluteString isEqualToString:self.url.stringValue]) {
|
||||
// URL was redirected, so replace original text field value with new one
|
||||
self.url.stringValue = response.URL.absoluteString;
|
||||
self.previousURL = self.url.stringValue;
|
||||
}
|
||||
// TODO: play error sound?
|
||||
self.feedError = error; // warning indicator .hidden is bound to feedError
|
||||
self.objectNeedsSaving = YES; // stays YES if this block runs after updateRepresentedObject:
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
#import "BarMenu.h"
|
||||
#import "StoreCoordinator.h"
|
||||
#import "FeedDownload.h"
|
||||
#import "DrawImage.h"
|
||||
#import "Preferences.h"
|
||||
#import "NSMenuItem+Info.h"
|
||||
@@ -44,12 +45,34 @@
|
||||
self.barItem = [NSStatusBar.systemStatusBar statusItemWithLength:NSVariableStatusItemLength];
|
||||
self.barItem.highlightMode = YES;
|
||||
[self rebuildMenu];
|
||||
// [self donothing];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(networkChange:) name:@"baRSS-notification-network-status-change" object:nil];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedUpdated:) name:@"baRSS-notification-feed-updated" object:nil];
|
||||
[FeedDownload registerNetworkChangeNotification];
|
||||
[FeedDownload performSelectorInBackground:@selector(scheduleNextUpdate:) withObject:[NSNumber numberWithBool:NO]];
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[FeedDownload unregisterNetworkChangeNotification];
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
}
|
||||
|
||||
- (void)networkChange:(NSNotification*)notify {
|
||||
BOOL available = [[notify object] boolValue];
|
||||
[self.barItem.menu itemWithTag:TagUpdateFeed].enabled = available;
|
||||
[self updateBarIcon];
|
||||
// TODO: Disable 'update all' menu item?
|
||||
}
|
||||
|
||||
- (void)feedUpdated:(NSNotification*)notify {
|
||||
FeedConfig *config = notify.object;
|
||||
NSLog(@"%@", config.indexPath);
|
||||
[self rebuildMenu];
|
||||
}
|
||||
|
||||
- (void)rebuildMenu {
|
||||
self.barItem.menu = [self generateMainMenu];
|
||||
[self updateBarIcon];
|
||||
}
|
||||
|
||||
- (void)donothing {
|
||||
@@ -79,13 +102,14 @@
|
||||
*/
|
||||
- (void)updateBarIcon {
|
||||
// TODO: Option: icon choice
|
||||
// TODO: Show paused icon if no internet connection
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (self.unreadCountTotal > 0 && [UserPrefs defaultYES:@"globalUnreadCount"]) {
|
||||
self.barItem.title = [NSString stringWithFormat:@"%d", self.unreadCountTotal];
|
||||
} else {
|
||||
self.barItem.title = @"";
|
||||
}
|
||||
|
||||
// BOOL hasNet = [FeedDownload isNetworkReachable];
|
||||
if (self.unreadCountTotal > 0 && [UserPrefs defaultYES:@"tintMenuBarIcon"]) {
|
||||
self.barItem.image = [RSSIcon templateIcon:16 tint:[NSColor rssOrange]];
|
||||
} else {
|
||||
@@ -122,7 +146,6 @@
|
||||
}
|
||||
}
|
||||
[self updateMenuHeaderEnabled:menu hasUnread:(self.unreadCountTotal > 0)];
|
||||
[self updateBarIcon];
|
||||
|
||||
[menu addItem:[NSMenuItem separatorItem]];
|
||||
|
||||
@@ -285,7 +308,8 @@
|
||||
}
|
||||
|
||||
- (void)updateAllFeeds:(NSMenuItem*)sender {
|
||||
NSLog(@"1update all");
|
||||
// TODO: Disable 'update all' menu item during update?
|
||||
[FeedDownload scheduleNextUpdate:YES];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
+ (void)saveContext:(NSManagedObjectContext*)context;
|
||||
+ (void)deleteUnreferencedFeeds;
|
||||
+ (NSArray<FeedConfig*>*)sortedFeedConfigItems;
|
||||
+ (NSArray<FeedConfig*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll;
|
||||
+ (NSDate*)nextScheduledUpdate;
|
||||
+ (id)objectWithID:(NSManagedObjectID*)objID;
|
||||
+ (Feed*)createFeedFrom:(RSParsedFeed*)obj inContext:(NSManagedObjectContext*)context;
|
||||
+ (void)overwriteConfig:(FeedConfig*)config withFeed:(RSParsedFeed*)obj;
|
||||
@end
|
||||
|
||||
@@ -63,16 +63,57 @@
|
||||
return result;
|
||||
}
|
||||
|
||||
+ (NSArray<FeedConfig*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll {
|
||||
NSManagedObjectContext *moc = [self getContext];
|
||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedConfig.entity.name];
|
||||
if (!forceAll) {
|
||||
fr.predicate = [NSPredicate predicateWithFormat:@"type = %d AND scheduled <= %@", FEED, [NSDate date]];
|
||||
} else {
|
||||
fr.predicate = [NSPredicate predicateWithFormat:@"type = %d", FEED];
|
||||
}
|
||||
NSError *err;
|
||||
NSArray *result = [moc executeFetchRequest:fr error:&err];
|
||||
if (err) NSLog(@"%@", err);
|
||||
return result;
|
||||
}
|
||||
|
||||
+ (NSDate*)nextScheduledUpdate {
|
||||
NSExpression *exp = [NSExpression expressionForFunction:@"min:" arguments:@[[NSExpression expressionForKeyPath:@"scheduled"]]];
|
||||
NSExpressionDescription *expDesc = [[NSExpressionDescription alloc] init];
|
||||
[expDesc setName:@"earliestDate"];
|
||||
[expDesc setExpression:exp];
|
||||
[expDesc setExpressionResultType:NSDateAttributeType];
|
||||
|
||||
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedConfig.entity.name];
|
||||
fr.predicate = [NSPredicate predicateWithFormat:@"type = %d", FEED];
|
||||
[fr setResultType:NSDictionaryResultType];
|
||||
[fr setPropertiesToFetch:@[expDesc]];
|
||||
|
||||
NSError *err;
|
||||
NSArray *fetchResults = [[self getContext] executeFetchRequest:fr error:&err];
|
||||
if (err) NSLog(@"%@", err);
|
||||
return [fetchResults firstObject][@"earliestDate"]; // can be nil
|
||||
}
|
||||
|
||||
+ (id)objectWithID:(NSManagedObjectID*)objID {
|
||||
return [[self getContext] objectWithID:objID];
|
||||
}
|
||||
|
||||
+ (Feed*)createFeedFrom:(RSParsedFeed*)obj inContext:(NSManagedObjectContext*)context {
|
||||
Feed *a = [[Feed alloc] initWithEntity:Feed.entity insertIntoManagedObjectContext:context];
|
||||
a.title = obj.title;
|
||||
a.subtitle = obj.subtitle;
|
||||
a.link = obj.link;
|
||||
for (RSParsedArticle *entry in obj.articles) {
|
||||
|
||||
+ (void)overwriteConfig:(FeedConfig*)config withFeed:(RSParsedFeed*)obj {
|
||||
NSArray<NSString*> *readURLs = [self alreadyReadURLsInFeed:config.feed];
|
||||
[config.managedObjectContext performBlockAndWait:^{
|
||||
if (config.feed)
|
||||
[config.managedObjectContext deleteObject:(NSManagedObject*)config.feed];
|
||||
if (obj) {
|
||||
config.feed = [StoreCoordinator createFeedFrom:obj inContext:config.managedObjectContext alreadyRead:readURLs];
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - Helper methods -
|
||||
|
||||
+ (FeedItem*)createFeedItemFrom:(RSParsedArticle*)entry inContext:(NSManagedObjectContext*)context {
|
||||
FeedItem *b = [[FeedItem alloc] initWithEntity:FeedItem.entity insertIntoManagedObjectContext:context];
|
||||
b.guid = entry.guid;
|
||||
b.title = entry.title;
|
||||
@@ -81,12 +122,33 @@
|
||||
b.author = entry.author;
|
||||
b.link = entry.link;
|
||||
b.published = entry.datePublished;
|
||||
// TODO: remove NSLog()
|
||||
if (!entry.datePublished)
|
||||
NSLog(@"No date for feed '%@'", obj.urlString);
|
||||
return b;
|
||||
}
|
||||
|
||||
+ (Feed*)createFeedFrom:(RSParsedFeed*)obj inContext:(NSManagedObjectContext*)context alreadyRead:(NSArray<NSString*>*)urls {
|
||||
Feed *a = [[Feed alloc] initWithEntity:Feed.entity insertIntoManagedObjectContext:context];
|
||||
a.title = obj.title;
|
||||
a.subtitle = obj.subtitle;
|
||||
a.link = obj.link;
|
||||
for (RSParsedArticle *article in obj.articles) {
|
||||
FeedItem *b = [self createFeedItemFrom:article inContext:context];
|
||||
if ([urls containsObject:b.link]) {
|
||||
b.unread = NO;
|
||||
}
|
||||
[a addItemsObject:b];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
+ (NSArray<NSString*>*)alreadyReadURLsInFeed:(Feed*)local {
|
||||
if (!local || !local.items) return nil;
|
||||
NSMutableArray<NSString*> *mArr = [NSMutableArray arrayWithCapacity:local.items.count];
|
||||
for (FeedItem *f in local.items) {
|
||||
if (!f.unread) {
|
||||
[mArr addObject:f.link];
|
||||
}
|
||||
}
|
||||
return mArr;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
Reference in New Issue
Block a user