Refactoring feed download + favicon cache

This commit is contained in:
relikd
2019-09-15 23:27:01 +02:00
parent ad607bc22b
commit 4075073d1b
36 changed files with 1360 additions and 797 deletions

View File

@@ -12,6 +12,7 @@ and this project does adhere to [Semantic Versioning](https://semver.org/spec/v2
- Associate OPML files (double click and right click actions in Finder)
- Quick Look preview for OPML files
- *Adding feed:* 5xx server errors have a reload button which will initiate a new download with the same URL
- *Adding feed:* Empty feed title will automatically reuse title from xml file (even if xml title changes)
- *Adding feed:* `⌘R` will reload the same URL
- *Settings, Feeds:* `⌘R` will reload the data source
- *Settings, Feeds:* Refresh interval string localizations
@@ -26,11 +27,12 @@ and this project does adhere to [Semantic Versioning](https://semver.org/spec/v2
- Config URL scheme `barss:` with `open/preferences` and `config/fixcache`
### Fixed
- *Adding feed:* Show users any 5xx server error response and extracted failure reason
- *Adding feed:* Show proper HTTP status code error message (4xx and 5xx)
- *Adding feed:* Show (HTML) extracted failure reason for 5xx server errors
- *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:* Prefer favicons with size `32x32`
- *Adding feed:* Inserting feeds when offline will postpone download until network is reachable again
- *Adding feed:* Inserting feeds when paused will postpone download until unpaused
- *Adding feed:* Inserting feeds when offline/paused will postpone download until network is reachable again
- *Adding feed:* `Cancel` will indeed cancel download, not just continue and ignore results
- *Settings, Feeds:* Actions `delete` and `edit` use clicked items instead of selected items
- *Settings, Feeds:* Status info with accurate download count (instead of `Updating feeds …`)
- *Settings, Feeds:* Status info shows `No network connection` and `Updates paused`
@@ -53,6 +55,7 @@ and this project does adhere to [Semantic Versioning](https://semver.org/spec/v2
- *UI:* Interface builder files replaced with code equivalent
- *UI:* Mark unread articles with blue dot, instead of tick mark
- *DB*: New table for options. E.g., what app version modified the database
- Dropping database table `FeedIcon` in favor of image files cache
## [0.9.4] - 2019-04-02

View File

@@ -17,6 +17,7 @@
544DCCB9212A2B4D002DBC46 /* RSXML.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 544DCCB8212A2B4D002DBC46 /* RSXML.framework */; };
544DCCBA212A2B4D002DBC46 /* RSXML.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 544DCCB8212A2B4D002DBC46 /* RSXML.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
544DCCBE212A2B6F002DBC46 /* RSXML.framework.dSYM in CopyFiles */ = {isa = PBXBuildFile; fileRef = 544DCCBD212A2B6F002DBC46 /* RSXML.framework.dSYM */; };
54501010230E9C8600F0B165 /* FeedDownload.m in Sources */ = {isa = PBXBuildFile; fileRef = 5450100F230E9C8600F0B165 /* FeedDownload.m */; };
546A6A2922C583390034E806 /* SettingsGeneralView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D857D122802309001BA1C8 /* SettingsGeneralView.m */; };
546A6A2C22C584AF0034E806 /* SettingsAppearanceView.m in Sources */ = {isa = PBXBuildFile; fileRef = 546A6A2A22C584AF0034E806 /* SettingsAppearanceView.m */; };
546A6A2F22C585580034E806 /* SettingsAboutView.m in Sources */ = {isa = PBXBuildFile; fileRef = 546A6A2D22C585580034E806 /* SettingsAboutView.m */; };
@@ -24,6 +25,7 @@
546FC44321189975007CC3A3 /* SettingsGeneral.m in Sources */ = {isa = PBXBuildFile; fileRef = 546FC44121189975007CC3A3 /* SettingsGeneral.m */; };
5477D34E21233C62002BA27F /* FeedGroup+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 5477D34D21233C62002BA27F /* FeedGroup+Ext.m */; };
5478DF04225A7AE200D30C64 /* SettingsFeedsView.m in Sources */ = {isa = PBXBuildFile; fileRef = 5478DF03225A7AE200D30C64 /* SettingsFeedsView.m */; };
548C6D0A230C33DE003A1AAF /* NSURL+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 548C6D09230C33DE003A1AAF /* NSURL+Ext.m */; };
5496B511214D6275003ED4ED /* UserPrefs.m in Sources */ = {isa = PBXBuildFile; fileRef = 5496B510214D6275003ED4ED /* UserPrefs.m */; };
54A07A7F220E04CF00082C51 /* NSFetchRequest+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54A07A7E220E04CF00082C51 /* NSFetchRequest+Ext.m */; };
54A07A82220E723D00082C51 /* MapUnreadTotal.m in Sources */ = {isa = PBXBuildFile; fileRef = 54A07A81220E723D00082C51 /* MapUnreadTotal.m */; };
@@ -31,11 +33,12 @@
54ACC28C21061B3C0020715F /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC28B21061B3C0020715F /* main.m */; };
54ACC29521061E270020715F /* UpdateScheduler.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC29421061E270020715F /* UpdateScheduler.m */; };
54ACC29821061FBA0020715F /* Preferences.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC29721061FBA0020715F /* Preferences.m */; };
54AD4E0023005297000AE386 /* WebFeed.m in Sources */ = {isa = PBXBuildFile; fileRef = 54AD4DFF23005297000AE386 /* WebFeed.m */; };
54AD4E0C2301853D000AE386 /* NSString+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54AD4E0B2301853D000AE386 /* NSString+Ext.m */; };
54AD4EE72305B17D000AE386 /* container-migration.plist in Resources */ = {isa = PBXBuildFile; fileRef = 54AD4EE62305B17D000AE386 /* container-migration.plist */; };
54B51704226DC339006C1B29 /* ModalFeedEditView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B51703226DC339006C1B29 /* ModalFeedEditView.m */; };
54B517072270E990006C1B29 /* NSView+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B517062270E92A006C1B29 /* NSView+Ext.m */; };
54B6F14A231551B3002C94C9 /* FaviconDownload.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B6F149231551B3002C94C9 /* FaviconDownload.m */; };
54B6F14E23155E1A002C94C9 /* NSURLRequest+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B6F14D23155E1A002C94C9 /* NSURLRequest+Ext.m */; };
54B749DA2204A85C0022CC6D /* BarStatusItem.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B749D92204A85C0022CC6D /* BarStatusItem.m */; };
54B749E0220636200022CC6D /* FeedArticle+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B749DF220635CD0022CC6D /* FeedArticle+Ext.m */; };
54BB048921FD2AB500C303A5 /* NSDate+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54BB048821FD2AB500C303A5 /* NSDate+Ext.m */; };
@@ -43,6 +46,7 @@
54D55D7322E624CD00057B98 /* SettingsFeeds+DragDrop.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D55D7222E624CD00057B98 /* SettingsFeeds+DragDrop.m */; };
54D857CE227C5785001BA1C8 /* RefreshStatisticsView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D857CD227C5785001BA1C8 /* RefreshStatisticsView.m */; };
54E3C02122EE076D006E2E24 /* opml-icon.icns in Resources */ = {isa = PBXBuildFile; fileRef = 54E3C02022EE076D006E2E24 /* opml-icon.icns */; };
54E4446C2329AE0600BBF481 /* NSError+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E4446B2329AE0600BBF481 /* NSError+Ext.m */; };
54E88320211B509D00064188 /* ModalFeedEdit.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E8831E211B509D00064188 /* ModalFeedEdit.m */; };
54E9CF32225914300023696F /* SettingsAbout.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E9CF31225914300023696F /* SettingsAbout.m */; };
54F39C2E210BE1F700AEE730 /* DBv1.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC28221061B3B0020715F /* DBv1.xcdatamodeld */; };
@@ -114,6 +118,8 @@
544B011C2114EE9100386E5C /* AppHook.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppHook.m; sourceTree = "<group>"; };
544DCCB8212A2B4D002DBC46 /* RSXML.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RSXML.framework; path = Carthage/Build/Mac/RSXML.framework; sourceTree = "<group>"; };
544DCCBD212A2B6F002DBC46 /* RSXML.framework.dSYM */ = {isa = PBXFileReference; lastKnownFileType = wrapper.dsym; name = RSXML.framework.dSYM; path = Carthage/Build/Mac/RSXML.framework.dSYM; sourceTree = "<group>"; };
5450100E230E9C8600F0B165 /* FeedDownload.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FeedDownload.h; sourceTree = "<group>"; };
5450100F230E9C8600F0B165 /* FeedDownload.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FeedDownload.m; sourceTree = "<group>"; };
546A6A2A22C584AF0034E806 /* SettingsAppearanceView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SettingsAppearanceView.m; sourceTree = "<group>"; };
546A6A2B22C584AF0034E806 /* SettingsAppearanceView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SettingsAppearanceView.h; sourceTree = "<group>"; };
546A6A2D22C585580034E806 /* SettingsAboutView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SettingsAboutView.m; sourceTree = "<group>"; };
@@ -127,6 +133,8 @@
5478DF02225A7AE200D30C64 /* SettingsFeedsView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SettingsFeedsView.h; sourceTree = "<group>"; };
5478DF03225A7AE200D30C64 /* SettingsFeedsView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SettingsFeedsView.m; sourceTree = "<group>"; };
54892F1D2235285700271CBA /* CHANGELOG.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = "<group>"; };
548C6D08230C33DE003A1AAF /* NSURL+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSURL+Ext.h"; sourceTree = "<group>"; };
548C6D09230C33DE003A1AAF /* NSURL+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSURL+Ext.m"; sourceTree = "<group>"; };
5496B50F214D6275003ED4ED /* UserPrefs.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UserPrefs.h; sourceTree = "<group>"; };
5496B510214D6275003ED4ED /* UserPrefs.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UserPrefs.m; sourceTree = "<group>"; };
54A07A7D220E04CF00082C51 /* NSFetchRequest+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSFetchRequest+Ext.h"; sourceTree = "<group>"; };
@@ -142,8 +150,6 @@
54ACC29421061E270020715F /* UpdateScheduler.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = UpdateScheduler.m; sourceTree = "<group>"; };
54ACC29621061FBA0020715F /* Preferences.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Preferences.h; sourceTree = "<group>"; };
54ACC29721061FBA0020715F /* Preferences.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = Preferences.m; sourceTree = "<group>"; };
54AD4DFE23005297000AE386 /* WebFeed.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WebFeed.h; sourceTree = "<group>"; };
54AD4DFF23005297000AE386 /* WebFeed.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = WebFeed.m; sourceTree = "<group>"; };
54AD4E0A2301853D000AE386 /* NSString+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSString+Ext.h"; sourceTree = "<group>"; };
54AD4E0B2301853D000AE386 /* NSString+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSString+Ext.m"; sourceTree = "<group>"; };
54AD4EE42305AF60000AE386 /* baRSS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = baRSS.entitlements; sourceTree = "<group>"; };
@@ -152,6 +158,10 @@
54B51703226DC339006C1B29 /* ModalFeedEditView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ModalFeedEditView.m; sourceTree = "<group>"; };
54B517052270E8C6006C1B29 /* NSView+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSView+Ext.h"; sourceTree = "<group>"; };
54B517062270E92A006C1B29 /* NSView+Ext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSView+Ext.m"; sourceTree = "<group>"; };
54B6F148231551B3002C94C9 /* FaviconDownload.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FaviconDownload.h; sourceTree = "<group>"; };
54B6F149231551B3002C94C9 /* FaviconDownload.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FaviconDownload.m; sourceTree = "<group>"; };
54B6F14C23155E1A002C94C9 /* NSURLRequest+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSURLRequest+Ext.h"; sourceTree = "<group>"; };
54B6F14D23155E1A002C94C9 /* NSURLRequest+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSURLRequest+Ext.m"; sourceTree = "<group>"; };
54B749D82204A85C0022CC6D /* BarStatusItem.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BarStatusItem.h; sourceTree = "<group>"; };
54B749D92204A85C0022CC6D /* BarStatusItem.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BarStatusItem.m; sourceTree = "<group>"; };
54B749DE220635BE0022CC6D /* FeedArticle+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FeedArticle+Ext.h"; sourceTree = "<group>"; };
@@ -166,6 +176,8 @@
54D857D022802309001BA1C8 /* SettingsGeneralView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SettingsGeneralView.h; sourceTree = "<group>"; };
54D857D122802309001BA1C8 /* SettingsGeneralView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SettingsGeneralView.m; sourceTree = "<group>"; };
54E3C02022EE076D006E2E24 /* opml-icon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = "opml-icon.icns"; sourceTree = "<group>"; };
54E4446A2329AE0600BBF481 /* NSError+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSError+Ext.h"; sourceTree = "<group>"; };
54E4446B2329AE0600BBF481 /* NSError+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSError+Ext.m"; sourceTree = "<group>"; };
54E8831D211B509D00064188 /* ModalFeedEdit.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ModalFeedEdit.h; sourceTree = "<group>"; };
54E8831E211B509D00064188 /* ModalFeedEdit.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ModalFeedEdit.m; sourceTree = "<group>"; };
54E9CF30225914300023696F /* SettingsAbout.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SettingsAbout.h; sourceTree = "<group>"; };
@@ -210,12 +222,18 @@
children = (
54209E922117325100F3B5EF /* DrawImage.h */,
54209E932117325100F3B5EF /* DrawImage.m */,
54BB048721FD2AB500C303A5 /* NSDate+Ext.h */,
54BB048821FD2AB500C303A5 /* NSDate+Ext.m */,
54B517052270E8C6006C1B29 /* NSView+Ext.h */,
54B517062270E92A006C1B29 /* NSView+Ext.m */,
54AD4E0A2301853D000AE386 /* NSString+Ext.h */,
54AD4E0B2301853D000AE386 /* NSString+Ext.m */,
54BB048721FD2AB500C303A5 /* NSDate+Ext.h */,
54BB048821FD2AB500C303A5 /* NSDate+Ext.m */,
54E4446A2329AE0600BBF481 /* NSError+Ext.h */,
54E4446B2329AE0600BBF481 /* NSError+Ext.m */,
548C6D08230C33DE003A1AAF /* NSURL+Ext.h */,
548C6D09230C33DE003A1AAF /* NSURL+Ext.m */,
54B6F14C23155E1A002C94C9 /* NSURLRequest+Ext.h */,
54B6F14D23155E1A002C94C9 /* NSURLRequest+Ext.m */,
);
path = Helper;
sourceTree = "<group>";
@@ -316,8 +334,10 @@
children = (
54ACC29321061E270020715F /* UpdateScheduler.h */,
54ACC29421061E270020715F /* UpdateScheduler.m */,
54AD4DFE23005297000AE386 /* WebFeed.h */,
54AD4DFF23005297000AE386 /* WebFeed.m */,
5450100E230E9C8600F0B165 /* FeedDownload.h */,
5450100F230E9C8600F0B165 /* FeedDownload.m */,
54B6F148231551B3002C94C9 /* FaviconDownload.h */,
54B6F149231551B3002C94C9 /* FaviconDownload.m */,
54F6025B21C1D4170006D338 /* OpmlFile.h */,
54F6025C21C1D4170006D338 /* OpmlFile.m */,
);
@@ -537,7 +557,6 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
54AD4E0023005297000AE386 /* WebFeed.m in Sources */,
54B51704226DC339006C1B29 /* ModalFeedEditView.m in Sources */,
54AD4E0C2301853D000AE386 /* NSString+Ext.m in Sources */,
546A6A2C22C584AF0034E806 /* SettingsAppearanceView.m in Sources */,
@@ -551,9 +570,12 @@
54ACC29521061E270020715F /* UpdateScheduler.m in Sources */,
54BB048921FD2AB500C303A5 /* NSDate+Ext.m in Sources */,
5477D34E21233C62002BA27F /* FeedGroup+Ext.m in Sources */,
54B6F14A231551B3002C94C9 /* FaviconDownload.m in Sources */,
54E4446C2329AE0600BBF481 /* NSError+Ext.m in Sources */,
5478DF04225A7AE200D30C64 /* SettingsFeedsView.m in Sources */,
540F704521B6C16C0022E69D /* FeedMeta+Ext.m in Sources */,
54ACC28C21061B3C0020715F /* main.m in Sources */,
54B6F14E23155E1A002C94C9 /* NSURLRequest+Ext.m in Sources */,
54FE73D3212316CD003EAC65 /* BarMenu.m in Sources */,
544B011A2114B41200386E5C /* ModalSheet.m in Sources */,
54A07A82220E723D00082C51 /* MapUnreadTotal.m in Sources */,
@@ -567,8 +589,10 @@
541C67C32255470B004D2CE6 /* SettingsAppearance.m in Sources */,
54B749DA2204A85C0022CC6D /* BarStatusItem.m in Sources */,
546FC43D21188AD5007CC3A3 /* SettingsFeeds.m in Sources */,
548C6D0A230C33DE003A1AAF /* NSURL+Ext.m in Sources */,
54E88320211B509D00064188 /* ModalFeedEdit.m in Sources */,
54195883218A061100581B79 /* Feed+Ext.m in Sources */,
54501010230E9C8600F0B165 /* FeedDownload.m in Sources */,
54209E942117325100F3B5EF /* DrawImage.m in Sources */,
54FE73D021220DEC003EAC65 /* StoreCoordinator.m in Sources */,
54A07A7F220E04CF00082C51 /* NSFetchRequest+Ext.m in Sources */,

View File

@@ -22,14 +22,14 @@
#import "AppHook.h"
#import "Constants.h"
#import "BarStatusItem.h"
#import "WebFeed.h"
#import "UpdateScheduler.h"
#import "Preferences.h"
#import "DrawImage.h"
#import "SettingsFeeds+DragDrop.h"
#import "UserPrefs.h"
#import "Preferences.h"
#import "BarStatusItem.h"
#import "UpdateScheduler.h"
#import "StoreCoordinator.h"
#import "SettingsFeeds+DragDrop.h"
#import "NSURL+Ext.h"
@interface AppHook()
@property (strong) NSWindowController *prefWindow;
@@ -53,11 +53,15 @@
}
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
BOOL initial = [[NSURL faviconsCacheURL] mkdir];
[_statusItem asyncReloadUnreadCount];
[UpdateScheduler registerNetworkChangeNotification]; // will call update scheduler
if ([StoreCoordinator isEmpty]) {
[_statusItem showWelcomeMessage];
[WebFeed autoDownloadAndParseUpdateURL];
[UpdateScheduler autoDownloadAndParseUpdateURL];
} else {
// mostly for version migration 0.9.4 ~> 1.0 (favicon storage)
if (initial) [UpdateScheduler updateAllFavicons];
}
}
@@ -109,8 +113,8 @@
@synthesize persistentContainer = _persistentContainer;
/// The persistent container for the application. This implementation creates and returns a container, having loaded the store for the application to it.
- (NSPersistentContainer *)persistentContainer {
// The persistent container for the application. This implementation creates and returns a container, having loaded the store for the application to it.
@synchronized (self) {
if (_persistentContainer == nil) {
_persistentContainer = [[NSPersistentContainer alloc] initWithName:@"DBv1"];
@@ -125,28 +129,23 @@
return _persistentContainer;
}
/// Save changes in the application's managed object context before the application terminates.
- (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sender {
// Save changes in the application's managed object context before the application terminates.
NSManagedObjectContext *context = self.persistentContainer.viewContext;
if (![context commitEditing]) {
NSLog(@"%@:%@ unable to commit editing to terminate", [self class], NSStringFromSelector(_cmd));
return NSTerminateCancel;
}
if (!context.hasChanges) {
return NSTerminateNow;
}
NSError *error = nil;
if (![context save:&error]) {
// Customize this code block to include application-specific recovery steps.
BOOL result = [sender presentError:error];
if (result) {
return NSTerminateCancel;
}
NSString *question = NSLocalizedString(@"Could not save changes while quitting. Quit anyway?", @"Quit without saves error question message");
NSString *info = NSLocalizedString(@"Quitting now will lose any changes you have made since the last successful save", @"Quit without saves error question info");
NSString *quitButton = NSLocalizedString(@"Quit anyway", @"Quit anyway button title");
@@ -157,9 +156,7 @@
[alert addButtonWithTitle:quitButton];
[alert addButtonWithTitle:cancelButton];
NSInteger answer = [alert runModal];
if (answer == NSAlertSecondButtonReturn) {
if ([alert runModal] == NSAlertSecondButtonReturn) {
return NSTerminateCancel;
}
}
@@ -170,9 +167,7 @@
#pragma mark - Application Input (URLs and Files)
/**
Callback method fired on opml file import
*/
/// Callback method fired on opml file import
- (void)application:(NSApplication *)sender openFiles:(NSArray<NSString *> *)filenames {
NSMutableArray<NSURL*> *urls = [NSMutableArray arrayWithCapacity:filenames.count];
for (NSString *file in filenames) {
@@ -184,9 +179,7 @@
[sender replyToOpenOrPrint:NSApplicationDelegateReplySuccess];
}
/**
Callback method fired when opened with an URL (@c feed: and @c barss: scheme)
*/
/// Callback method fired when opened with an URL (@c feed: and @c barss: scheme)
- (void)handleAppleEvent:(NSAppleEventDescriptor *)event withReplyEvent:(NSAppleEventDescriptor *)replyEvent {
NSString *url = [[event paramDescriptorForKeyword:keyDirectObject] stringValue];
NSString *scheme = [[[NSURL URLWithString:url] scheme] lowercaseString];
@@ -195,7 +188,7 @@
url = [url substringFromIndex:2];
}
if ([scheme isEqualToString:kURLSchemeFeed]) {
[WebFeed autoDownloadAndParseURL:url addAnyway:NO modify:nil];
[UpdateScheduler autoDownloadAndParseURL:url];
} else if ([scheme isEqualToString:kURLSchemeBarss]) {
NSMutableArray<NSString*> *comp = [[url pathComponents] mutableCopy];
NSString *action = comp.firstObject;

View File

@@ -61,8 +61,8 @@ static NSImageName const RSSImageMenuItemUnread = @"RSSImageMenuItemUnread";
/// Helper method calls @c (defaultCenter)postNotification:
NS_INLINE void PostNotification(NSNotificationName name, id obj) { [[NSNotificationCenter defaultCenter] postNotificationName:name object:obj]; }
NS_INLINE void RegisterNotification(NSNotificationName name, SEL action, id observer) { [[NSNotificationCenter defaultCenter] addObserver:observer selector:action name:name object:nil]; }
static inline void PostNotification(NSNotificationName name, id obj) { [[NSNotificationCenter defaultCenter] postNotificationName:name object:obj]; }
static inline void RegisterNotification(NSNotificationName name, SEL action, id observer) { [[NSNotificationCenter defaultCenter] addObserver:observer selector:action name:name object:nil]; }
/**
@c notification.object is @c NSNumber of type @c NSUInteger.
Represents number of feeds that are proccessed in background update. Sends @c 0 when all downloads are finished.
@@ -77,12 +77,12 @@ static NSNotificationName const kNotificationScheduleTimerChanged = @"baRSS-noti
@c notification.object is @c NSManagedObjectID of type @c FeedGroup.
Called whenever a new feed group was created in @c autoDownloadAndParseURL:
*/
static NSNotificationName const kNotificationGroupInserted = @"baRSS-notification-group-inserted";
static NSNotificationName const kNotificationFeedGroupInserted = @"baRSS-notification-feed-inserted";
/**
@c notification.object is @c NSManagedObjectID of type @c Feed.
Called whenever download of a feed finished and object was modified (not if statusCode 304).
Called whenever download of a feed finished and articles were modified (not if statusCode 304).
*/
static NSNotificationName const kNotificationFeedUpdated = @"baRSS-notification-feed-updated";
static NSNotificationName const kNotificationArticlesUpdated = @"baRSS-notification-articles-updated";
/**
@c notification.object is @c NSManagedObjectID of type @c Feed.
Called whenever the icon attribute of an item was updated.

View File

@@ -25,16 +25,16 @@
@class RSParsedFeed;
@interface Feed (Ext)
@property (readonly) BOOL hasIcon;
@property (nonnull, readonly) NSImage* iconImage16;
// Generator methods / Feed update
+ (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context;
+ (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc;
- (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag;
- (void)calculateAndSetIndexPathString;
- (NSMenuItem*)newMenuItem;
// Getter & Setter
- (void)calculateAndSetIndexPathString;
- (void)setNewIcon:(NSURL*)location;
// Article properties
- (NSArray<FeedArticle*>*)sortedArticles;
// Icon
- (BOOL)setIconImage:(NSImage*)img;
@end

View File

@@ -28,6 +28,7 @@
#import "FeedGroup+Ext.h"
#import "FeedArticle+Ext.h"
#import "StoreCoordinator.h"
#import "NSURL+Ext.h"
@implementation Feed (Ext)
@@ -38,14 +39,6 @@
return feed;
}
/// Instantiates new @c FeedGroup with @c FEED type, set the update interval to @c 30min and @c sortIndex to last root index.
+ (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc {
NSUInteger lastIndex = [StoreCoordinator countRootItemsInContext:moc];
FeedGroup *fg = [FeedGroup newGroup:FEED inContext:moc];
[fg setParent:nil andSortIndex:(int32_t)lastIndex];
return fg.feed;
}
/// Call @c indexPathString on @c .group and update @c .indexPath if current value is different.
- (void)calculateAndSetIndexPathString {
NSString *pthStr = [self.group indexPathString];
@@ -56,7 +49,7 @@
/// @return Fully initialized @c NSMenuItem with @c title, @c tooltip, @c image, and @c action.
- (NSMenuItem*)newMenuItem {
NSMenuItem *item = [NSMenuItem new];
item.title = self.group.nameOrError;
item.title = self.group.anyName;
item.toolTip = self.subtitle;
item.enabled = (self.articles.count > 0);
item.image = self.iconImage16;
@@ -85,9 +78,6 @@
if (![self.subtitle isEqualToString:obj.subtitle]) self.subtitle = obj.subtitle;
if (![self.link isEqualToString:obj.link]) self.link = obj.link;
if (self.group.name.length == 0) // in case a blank group was initialized
self.group.name = obj.title;
// Add and remove articles
NSMutableSet<FeedArticle*> *localSet = [self.articles mutableCopy];
NSInteger diff = 0;
@@ -117,6 +107,7 @@
// reverse enumeration ensures correct article order
FeedArticle *storedArticle = [self findRemoteArticle:article inLocalSet:localSet];
if (storedArticle) {
// TODO: stop bullshitting with ghost articles
[localSet removeObject:storedArticle];
// If we encounter an already existing item, assume newly inserted are "ghost" items and mark read.
if (newlyInserted.count > 0) {
@@ -215,15 +206,13 @@
#pragma mark - Icon -
/**
@return Return @c 16x16px image. Either from core data storage or generated default RSS icon.
*/
/// @return @c 16x16px image. Either from favicon cache or generated default RSS icon.
- (nonnull NSImage*)iconImage16 {
NSImage *img = nil;
if (self.articles.count == 0) {
img = [NSImage imageNamed:NSImageNameCaution];
} else if (self.icon.icon) {
img = [[NSImage alloc] initWithData:self.icon.icon];
} else if (self.hasIcon) {
img = [[NSImage alloc] initByReferencingURL:[self iconPath]];
} else {
img = [NSImage imageNamed:RSSImageDefaultRSSIcon];
}
@@ -231,23 +220,26 @@
return img;
}
/**
Set favicon icon or delete relationship if @c img is not a valid image.
/// Checks if file at @c iconPath is an actual file
- (BOOL)hasIcon { return [[self iconPath] existsAndIsDir:NO]; }
@return @c YES if icon was updated (core data did change).
*/
- (BOOL)setIconImage:(NSImage*)img {
if (img && [img isValid]) {
if (!self.icon)
self.icon = [[FeedIcon alloc] initWithEntity:FeedIcon.entity insertIntoManagedObjectContext:self.managedObjectContext];
self.icon.icon = [img TIFFRepresentation];
return YES;
} else if (self.icon) {
[self.managedObjectContext deleteObject:self.icon];
self.icon = nil;
return YES;
/// Image file path at e.g., "Application Support/baRSS/favicons/p42". @warning File may not exist!
- (NSURL*)iconPath {
NSString *pk = self.objectID.URIRepresentation.lastPathComponent;
return [[NSURL faviconsCacheURL] URLByAppendingPathComponent:pk isDirectory:NO];
}
/// Move favicon from @c $TMPDIR to permanent destination in Application Support.
- (void)setNewIcon:(NSURL*)location {
if (!location) {
[[self iconPath] remove];
} else {
if (self.objectID.isTemporaryID) {
[self.managedObjectContext obtainPermanentIDsForObjects:@[self] error:nil];
}
[location moveTo:[self iconPath]];
PostNotification(kNotificationFeedIconUpdated, self.objectID);
}
return NO;
}
@end

View File

@@ -33,14 +33,15 @@ typedef NS_ENUM(int16_t, FeedGroupType) {
@interface FeedGroup (Ext)
/// Overwrites @c type attribute with enum. Use one of: @c GROUP, @c FEED, @c SEPARATOR.
@property (nonatomic) FeedGroupType type;
@property (nonnull, readonly) NSString *nameOrError;
@property (nonnull, readonly) NSString *anyName;
@property (nonnull, readonly) NSImage* groupIconImage16;
@property (nonnull, readonly) NSImage* iconImage16;
+ (instancetype)newGroup:(FeedGroupType)type inContext:(NSManagedObjectContext*)context;
+ (instancetype)appendToRoot:(FeedGroupType)type inContext:(NSManagedObjectContext*)moc;
- (void)setParent:(FeedGroup *)parent andSortIndex:(int32_t)sortIndex;
- (void)setSortIndexIfChanged:(int32_t)sortIndex;
- (void)setNameIfChanged:(NSString*)name;
- (void)setNameIfChanged:(nullable NSString*)name;
- (NSMenuItem*)newMenuItem;
// Handle children and parents
- (NSString*)indexPathString;

View File

@@ -21,17 +21,22 @@
// SOFTWARE.
#import "FeedGroup+Ext.h"
#import "FeedMeta+Ext.h"
#import "Feed+Ext.h"
#import "FeedMeta+Ext.h"
#import "StoreCoordinator.h"
#import "NSDate+Ext.h"
@implementation FeedGroup (Ext)
#pragma mark - Properties
/// @return Returns "(no title)" if @c self.name is @c nil.
- (nonnull NSString*)nameOrError {
return (self.name ? self.name : NSLocalizedString(@"(no title)", nil));
/// Try return @c self.name or @c self.feed.title ; If both fail return "(no title)"
- (nonnull NSString*)anyName {
if (self.name.length > 0)
return self.name;
if (self.type == FEED && self.feed.title.length > 0)
return self.feed.title;
return NSLocalizedString(@"(no title)", nil);
}
/// @return Return @c 16x16px NSImageNameFolder image.
@@ -66,6 +71,14 @@
return fg;
}
/// Instantiates new @c FeedGroup at root level and append at end.
+ (instancetype)appendToRoot:(FeedGroupType)type inContext:(NSManagedObjectContext*)moc {
NSUInteger lastIndex = [StoreCoordinator countRootItemsInContext:moc];
FeedGroup *fg = [FeedGroup newGroup:type inContext:moc];
[fg setParent:nil andSortIndex:(int32_t)lastIndex];
return fg;
}
/// Set @c parent and @c sortIndex. Also if type is @c FEED calculate and set @c indexPath string.
- (void)setParent:(FeedGroup *)parent andSortIndex:(int32_t)sortIndex {
self.parent = parent;
@@ -85,15 +98,19 @@
}
/// Set @c name attribute but only if value differs.
- (void)setNameIfChanged:(NSString*)name {
if (![self.name isEqualToString: name])
- (void)setNameIfChanged:(nullable NSString*)name {
if (name.length == 0) {
if (self.name.length > 0)
self.name = nil; // nullify empty strings
} else if (![self.name isEqualToString: name]) {
self.name = name;
}
}
/// @return Fully initialized @c NSMenuItem with @c title and @c image.
- (NSMenuItem*)newMenuItem {
NSMenuItem *item = [NSMenuItem new];
item.title = self.nameOrError;
item.title = self.anyName;
item.enabled = (self.children.count > 0);
item.image = self.groupIconImage16;
item.representedObject = self.objectID;
@@ -155,8 +172,8 @@
/// @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 GROUP: return [NSString stringWithFormat:@"%@:", self.anyName];
case FEED: return [NSString stringWithFormat:@"%@ (%@)", self.anyName, self.feed.meta.url];
case SEPARATOR: return @"-------------";
}
}

View File

@@ -32,6 +32,6 @@ static int32_t const kDefaultFeedRefreshInterval = 30 * 60;
- (void)setSucessfulWithResponse:(NSHTTPURLResponse*)response;
// Setter
- (void)setUrlIfChanged:(NSString*)url;
- (BOOL)setRefreshAndSchedule:(int32_t)refresh;
- (void)setRefreshIfChanged:(int32_t)refresh;
- (void)scheduleNow:(NSTimeInterval)future;
@end

View File

@@ -51,6 +51,7 @@
[self scheduleNow:retryWaitTime];
}
/// Copy Etag & Last-Modified headers and update URL (if not 304). Then schedule new update date. Will reset errorCount to @c 0
- (void)setSucessfulWithResponse:(NSHTTPURLResponse*)response {
self.errorCount = 0; // reset counter
NSDictionary *header = [response allHeaderFields];
@@ -68,26 +69,17 @@
if (![self.url isEqualToString:url]) self.url = url;
}
/// Set @c refresh attribute but only if value differs.
- (void)setRefreshIfChanged:(int32_t)refresh {
if (self.refresh != refresh) self.refresh = refresh;
}
/// Set @c etag and @c modified attributes. Only values that differ will be updated.
- (void)setEtag:(NSString*)etag modified:(NSString*)modified {
if (![self.etag isEqualToString:etag]) self.etag = etag;
if (![self.modified isEqualToString:modified]) self.modified = modified;
}
/**
Set @c refresh and calculate new @c scheduled date.
@return @c YES if refresh interval has changed
*/
- (BOOL)setRefreshAndSchedule:(int32_t)refresh {
if (self.refresh != refresh) {
self.refresh = refresh;
[self scheduleNow:self.refresh];
return YES;
}
return NO;
}
/// Set next scheduled feed update or @c nil if @c refresh @c <= @c 0.
- (void)scheduleNow:(NSTimeInterval)future {
if (self.refresh <= 0) { // update deactivated; manually update with force update all

View File

@@ -36,7 +36,7 @@ static int const dbFileVersion = 1; // update in case database structure changes
// Feed update
+ (NSDate*)nextScheduledUpdate;
+ (NSArray<Feed*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc;
+ (NSArray<Feed*>*)listOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc;
// Count elements
+ (BOOL)isEmpty;
@@ -48,7 +48,6 @@ static int const dbFileVersion = 1; // update in case database structure changes
+ (NSArray<FeedGroup*>*)sortedFeedGroupsWithParent:(id)parent inContext:(NSManagedObjectContext*)moc;
+ (NSArray<FeedArticle*>*)sortedArticlesWithParent:(id)parent inContext:(NSManagedObjectContext*)moc;
+ (NSArray<Feed*>*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc;
+ (NSArray<Feed*>*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc;
+ (Feed*)feedWithIndexPath:(nonnull NSString*)path inContext:(NSManagedObjectContext*)moc;
+ (NSString*)urlForFeedWithIndexPath:(nonnull NSString*)path;
@@ -57,4 +56,5 @@ static int const dbFileVersion = 1; // update in case database structure changes
// Restore sound state
+ (void)cleanupAndShowAlert:(BOOL)flag;
+ (NSUInteger)cleanupFavicons;
@end

View File

@@ -21,10 +21,12 @@
// SOFTWARE.
#import "StoreCoordinator.h"
#import "Constants.h"
#import "NSFetchRequest+Ext.h"
#import "AppHook.h"
#import "Constants.h"
#import "FaviconDownload.h"
#import "Feed+Ext.h"
#import "NSURL+Ext.h"
#import "NSFetchRequest+Ext.h"
@implementation StoreCoordinator
@@ -57,7 +59,7 @@
NSError *error = nil;
if (context.hasChanges && ![context save:&error]) {
// Customize this code block to include application-specific recovery steps.
[[NSApplication sharedApplication] presentError:error];
[NSApp presentError:error];
}
if (flag && context.parentContext) {
[self saveContext:context.parentContext andParent:flag];
@@ -101,11 +103,11 @@
@param forceAll If @c YES get a list of all @c Feed regardless of schedules time.
*/
+ (NSArray<Feed*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc {
+ (NSArray<Feed*>*)listOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc {
NSFetchRequest *fr = [Feed fetchRequest];
if (!forceAll) {
// when fetching also get those feeds that would need update soon (now + 10s)
[fr where:@"meta.scheduled <= %@", [NSDate dateWithTimeIntervalSinceNow:+10]];
// when fetching also get those feeds that would need update soon (now + 2s)
[fr where:@"meta.scheduled <= %@", [NSDate dateWithTimeIntervalSinceNow:+2]];
}
return [fr fetchAllRows:moc];
}
@@ -156,11 +158,6 @@
return [[[Feed fetchRequest] where:@"articles.@count == 0"] fetchAllRows:moc];
}
/// @return Unsorted list of @c Feed items where @c icon is @c nil.
+ (NSArray<Feed*>*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc {
return [[[Feed fetchRequest] where:@"icon = NULL"] fetchAllRows:moc];
}
/// @return Single @c Feed item where @c Feed.indexPath @c = @c path.
+ (Feed*)feedWithIndexPath:(nonnull NSString*)path inContext:(NSManagedObjectContext*)moc {
return [[[Feed fetchRequest] where:@"indexPath = %@", path] fetchFirst:moc];
@@ -225,6 +222,7 @@
#pragma mark - Restore Sound State
/// Remove orphan core data entries with optional alert message of removed items count.
+ (void)cleanupAndShowAlert:(BOOL)flag {
NSUInteger deleted = [self deleteUnreferenced];
[self restoreFeedIndexPaths];
@@ -256,7 +254,6 @@
NSManagedObjectContext *moc = [self getMainContext];
deleted += [self batchDelete:Feed.entity nullAttribute:@"group" inContext:moc];
deleted += [self batchDelete:FeedMeta.entity nullAttribute:@"feed" inContext:moc];
deleted += [self batchDelete:FeedIcon.entity nullAttribute:@"feed" inContext:moc];
deleted += [self batchDelete:FeedArticle.entity nullAttribute:@"feed" inContext:moc];
if (deleted > 0) {
[self saveContext:moc andParent:YES];
@@ -291,4 +288,25 @@
return [res.result unsignedIntegerValue];
}
/// Remove orphan favicons. @return Number of removed items.
+ (NSUInteger)cleanupFavicons {
NSURL *base = [[NSURL faviconsCacheURL] URLByResolvingSymlinksInPath];
if (![base existsAndIsDir:YES]) return 0;
NSFileManager *fm = [NSFileManager defaultManager];
NSDirectoryEnumerationOptions opt = NSDirectoryEnumerationSkipsSubdirectoryDescendants | NSDirectoryEnumerationSkipsPackageDescendants | NSDirectoryEnumerationSkipsHiddenFiles;
NSDirectoryEnumerator *enumerator = [fm enumeratorAtURL:base includingPropertiesForKeys:nil options:opt errorHandler:nil];
NSMutableArray<NSURL*> *toBeDeleted = [NSMutableArray array];
NSArray<NSManagedObjectID*> *feedIds = [[Feed fetchRequest] fetchIDs:[self getMainContext]];
NSArray<NSString*> *pks = [feedIds valueForKeyPath:@"URIRepresentation.lastPathComponent"];
for (NSURL *path in enumerator)
if (![pks containsObject:path.lastPathComponent])
[toBeDeleted addObject:path];
for (NSURL *path in toBeDeleted)
[fm removeItemAtURL:path error:nil];
return toBeDeleted.count;
}
@end

View File

@@ -7,7 +7,6 @@
<attribute name="title" optional="YES" attributeType="String" syncable="YES"/>
<relationship name="articles" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="FeedArticle" inverseName="feed" inverseEntity="FeedArticle" syncable="YES"/>
<relationship name="group" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="FeedGroup" inverseName="feed" inverseEntity="FeedGroup" syncable="YES"/>
<relationship name="icon" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="FeedIcon" inverseName="feed" inverseEntity="FeedIcon" syncable="YES"/>
<relationship name="meta" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="FeedMeta" inverseName="feed" inverseEntity="FeedMeta" syncable="YES"/>
</entity>
<entity name="FeedArticle" representedClassName="FeedArticle" syncable="YES" codeGenerationType="class">
@@ -30,10 +29,6 @@
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Feed" inverseName="group" inverseEntity="Feed" syncable="YES"/>
<relationship name="parent" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="FeedGroup" inverseName="children" inverseEntity="FeedGroup" syncable="YES"/>
</entity>
<entity name="FeedIcon" representedClassName="FeedIcon" syncable="YES" codeGenerationType="class">
<attribute name="icon" optional="YES" attributeType="Binary" allowsExternalBinaryDataStorage="YES" customClassName="NSImage" syncable="YES"/>
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="icon" inverseEntity="Feed" syncable="YES"/>
</entity>
<entity name="FeedMeta" representedClassName="FeedMeta" syncable="YES" codeGenerationType="class">
<attribute name="errorCount" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<attribute name="etag" optional="YES" attributeType="String" syncable="YES"/>
@@ -48,10 +43,9 @@
<attribute name="value" optional="YES" attributeType="String" syncable="YES"/>
</entity>
<elements>
<element name="Feed" positionX="-278.84765625" positionY="-112.953125" width="128" height="165"/>
<element name="Feed" positionX="-278.84765625" positionY="-112.953125" width="128" height="150"/>
<element name="FeedArticle" positionX="-96.77734375" positionY="-113.83984375" width="128" height="195"/>
<element name="FeedGroup" positionX="-460.37890625" positionY="-111.62890625" width="130.52734375" height="135"/>
<element name="FeedIcon" positionX="-202.79296875" positionY="137.71875" width="128" height="75"/>
<element name="FeedMeta" positionX="-348.02734375" positionY="136.89453125" width="128" height="150"/>
<element name="Options" positionX="-279" positionY="36" width="128" height="75"/>
</elements>

View File

@@ -0,0 +1,47 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
@class Feed, RSHTMLMetadata, FeedDownload;
@protocol FaviconDownloadDelegate;
@interface FaviconDownload : NSObject
/// @c img and @c path are @c nil if image is not valid or couldn't be downloaded.
typedef void(^FaviconDownloadBlock)(NSImage * _Nullable img, NSURL * _Nullable path);
// Instantiation methods
+ (instancetype)withURL:(nonnull NSString*)urlStr isImageURL:(BOOL)flag;
+ (instancetype)updateFeed:(Feed*)feed finally:(nullable os_block_t)block;
// Actions
- (instancetype)startWithDelegate:(id<FaviconDownloadDelegate>)observer;
- (instancetype)startWithBlock:(nonnull FaviconDownloadBlock)block;
- (void)cancel;
// Extract from HTML metadata
+ (nullable NSString*)urlForMetadata:(RSHTMLMetadata*)meta;
@end
@protocol FaviconDownloadDelegate <NSObject>
@required
/// Called after image download. Called on error, but not if download is cancled.
- (void)faviconDownload:(FaviconDownload*)sender didFinish:(nullable NSURL*)path;
@end

View File

@@ -0,0 +1,226 @@
//
// 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 RSXML;
#import "FaviconDownload.h"
#import "Feed+Ext.h"
#import "FeedMeta+Ext.h"
#import "NSURLRequest+Ext.h"
@interface FaviconDownload()
@property (nonatomic, weak) id<FaviconDownloadDelegate> delegate;
@property (nonatomic, strong) FaviconDownloadBlock block;
@property (nonatomic, weak) NSURLSessionTask *currentDownload;
@property (nonatomic, assign) BOOL canceled;
@property (nonatomic, assign) BOOL assertIsImageURL; // prohibit processing of HTML data
@property (nonatomic, strong) NSURL *remoteURL; // remote absolute path
@property (nonatomic, strong) NSURL *hostURL; // remote base domain
@property (nonatomic, strong) NSURL *fileURL; // local location
@end
@implementation FaviconDownload
// ---------------------------------------------------------------
// | MARK: - Class methods
// ---------------------------------------------------------------
/**
Start favicon download request on existing @c Feed object.
@note Will post a @c kNotificationFeedIconUpdated notification on success.
*/
+ (instancetype)updateFeed:(Feed*)feed finally:(nullable os_block_t)block {
NSString *url = feed.link;
if (!url) url = feed.meta.url;
NSManagedObjectContext *moc = feed.managedObjectContext;
NSManagedObjectID *oid = feed.objectID;
return [[self withURL:url isImageURL:NO] startWithBlock:^(NSImage * _Nullable img, NSURL * _Nullable path) {
if (path) [(Feed*)[moc objectWithID:oid] setNewIcon:path];
if (block) block();
}];
}
/**
Instantiate new loader from URL.
@param flag If @c YES skip parsing of html.
*/
+ (instancetype)withURL:(nonnull NSString*)urlStr isImageURL:(BOOL)flag {
FaviconDownload *this = [super new];
this.remoteURL = [NSURL URLWithString:urlStr];
this.assertIsImageURL = flag;
return this;
}
// ---------------------------------------------------------------
// | MARK: - Actions
// ---------------------------------------------------------------
/// Start download request and notify @c oberserver during the various steps.
- (instancetype)startWithDelegate:(id<FaviconDownloadDelegate>)observer {
self.delegate = observer;
[self performSelectorInBackground:@selector(start) withObject:nil];
return self;
}
/// Start download request and notify @c block once finished.
- (instancetype)startWithBlock:(nonnull FaviconDownloadBlock)block {
self.block = block;
[self performSelectorInBackground:@selector(start) withObject:nil];
return self;
}
/// Cancel running download task immediately. Will notify neither @c delegate nor @c block
- (void)cancel {
self.canceled = YES;
self.delegate = nil;
self.block = nil;
[self.currentDownload cancel];
}
/// Called for both; delegate and block observer.
- (void)start {
if (self.canceled)
return;
// Base URL part. E.g., https://stackoverflow.com/a/15897956/10616114 ==> https://stackoverflow.com/
self.hostURL = [[NSURL URLWithString:@"/" relativeToURL:self.remoteURL] absoluteURL];
self.assertIsImageURL ? [self continueWithImageDownload] : [self continueWithHTMLDownload];
}
/// Start request on HTML metadata and try parsing it. Will update @c remoteURL (@c nil on error)
- (void)continueWithHTMLDownload {
if (self.canceled)
return;
self.remoteURL = nil;
self.currentDownload = [[NSURLRequest requestWithURL:self.hostURL] dataTask:^(NSData * _Nullable htmlData, NSError * _Nullable error, NSHTTPURLResponse *response) {
if (self.canceled)
return;
if (htmlData) {
// TODO: use session delegate to stop download after <head>
RSXMLData *xml = [[RSXMLData alloc] initWithData:htmlData url:response.URL];
RSHTMLMetadataParser *parser = [RSHTMLMetadataParser parserWithXMLData:xml];
RSHTMLMetadata *meta = [parser parseSync:&error];
if (error) meta = nil;
NSString *u = [FaviconDownload urlForMetadata:meta];
if (u) self.remoteURL = [NSURL URLWithString:u];
}
[self continueWithImageDownload];
}];
}
/// Choose action based on whether @c .remoteURL is set.
- (void)continueWithImageDownload {
if (self.canceled)
return;
self.remoteURL ? [self loadImageFromRemoteURL] : [self loadImageFromDefaultLocation];
}
/// Download image from default location @c /favicon.ico
- (void)loadImageFromDefaultLocation {
self.remoteURL = [self.hostURL URLByAppendingPathComponent:@"favicon.ico"];
self.hostURL = nil; // prevent recursion in loadImageFromRemoteURL
[self loadImageFromRemoteURL];
}
/// Start download of favicon whether from already parsed favicon URL or default location.
- (void)loadImageFromRemoteURL {
if (self.canceled)
return;
self.currentDownload = [[NSURLRequest requestWithURL:self.remoteURL] downloadTask:^(NSURL * _Nullable path, NSError * _Nullable error) {
if (error) path = nil; // will also nullify img
NSImage *img = path ? [[NSImage alloc] initByReferencingURL:path] : nil;
if (img.valid) {
NSString *tmp = NSProcessInfo.processInfo.globallyUniqueString;
NSURL *dest = [path URLByDeletingLastPathComponent];
dest = [dest URLByAppendingPathComponent:tmp isDirectory:NO];
// move image to temporary destination, otherwise dataTask: will delete it.
[[NSFileManager defaultManager] moveItemAtURL:path toURL:dest error:nil];
self.fileURL = dest;
} else if (self.hostURL) {
[self loadImageFromDefaultLocation]; // starts a new request
return;
}
[self finishAndNotify];
}];
}
/// Called after trying all favicon URLs. May be @c nil if none of the URLs were successful.
- (void)finishAndNotify {
if (self.canceled)
return;
NSURL *path = self.fileURL;
NSImage *img = [[NSImage alloc] initByReferencingURL:path];
if (!img.valid) { path = nil; img = nil; }
#ifdef DEBUG
printf("ICON %1.0fx%1.0f %s\n", img.size.width, img.size.height, self.remoteURL.absoluteString.UTF8String);
printf(" ↳ %s\n", path.absoluteString.UTF8String);
#endif
dispatch_async(dispatch_get_main_queue(), ^{
[self.delegate faviconDownload:self didFinish:path];
if (self.block) { self.block(img, path); self.block = nil; }
});
}
// ---------------------------------------------------------------
// | MARK: - Extract from HTML metadata
// ---------------------------------------------------------------
/// Extract favicon URL from parsed HTML metadata.
+ (nullable NSString*)urlForMetadata:(RSHTMLMetadata*)meta {
if (!meta) return nil;
double bestScore = DBL_MAX;
NSString *iconURL = nil;
if (meta.faviconLink.length > 0) {
bestScore = ScoreIcon(nil);
iconURL = meta.faviconLink; // Replaced below if size is between 18 and 56
}
if (meta.iconLinks.count > 0) {
for (RSHTMLMetadataIconLink *icon in meta.iconLinks) {
double currentScore = ScoreIcon(icon);
if (currentScore < bestScore) {
bestScore = currentScore;
iconURL = icon.link;
}
}
if (!iconURL) // return first, even if all items in list have size 0
return meta.iconLinks.firstObject.link;
}
return iconURL;
}
/// Find icon with closest matching size 32x32 (lower score means better match)
static double ScoreIcon(RSHTMLMetadataIconLink *icon) {
if ([icon.sizes isEqualToString:@"any"])
return DBL_MAX; // exclude svg
CGSize size = [icon getSize];
double area = size.width * size.height;
if (area <= 0) {
if ([icon.title hasPrefix:@"apple-touch-icon"])
area = 180 * 180; // https://webhint.io/docs/user-guide/hints/hint-apple-touch-icons/
else
area = 18 * 18; // Size could be 16, 32, or 48. Assuming its better than 16px.
}
double match = log10(area) - log10(32 * 32);
return fabs(match) + (match < 0 ? 1e-5 : 0); // slightly prefer larger icons (64px over 16px)
}
@end

View File

@@ -0,0 +1,63 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
@class RSParsedFeed, RSHTMLMetadataFeedLink, Feed, FaviconDownload;
@protocol FeedDownloadDelegate;
/**
All properties will be parsed and stored in local variables.
This will avoid unnecessary core data operations if user decides to cancel the edit.
*/
@interface FeedDownload : NSObject
@property (readonly, nonnull) NSURLRequest *request;
@property (readonly, nullable) NSHTTPURLResponse* response;
@property (readonly, nullable) RSParsedFeed *xmlfeed;
@property (readonly, nullable) NSError *error;
@property (readonly, nullable) NSString *faviconURL;
typedef void (^FeedDownloadBlock)(FeedDownload *sender);
// Instantiation methods
+ (instancetype)withURL:(NSString*)url;
+ (instancetype)withFeed:(Feed*)feed forced:(BOOL)flag;
// Actions
- (instancetype)startWithDelegate:(id<FeedDownloadDelegate>)delegate;
- (instancetype)startWithBlock:(nonnull FeedDownloadBlock)block;
- (void)cancel;
- (BOOL)copyValuesTo:(nonnull Feed*)feed ignoreError:(BOOL)flag;
// Getter
- (FaviconDownload*)faviconDownload;
@end
/// Protocol for handling an in memory download
@protocol FeedDownloadDelegate <NSObject>
@optional
/// Delegate must return chosen URL. If not implemented, the first URL will be used.
- (NSString*)feedDownload:(FeedDownload*)sender selectFeedFromList:(NSArray<RSHTMLMetadataFeedLink*>*)list;
/// Only called if an URL redirect occured.
- (void)feedDownload:(FeedDownload*)sender urlRedirected:(NSString*)newURL;
/// Called after xml data is loaded and parsed. Called on error, but not if download is cancled.
- (void)feedDownloadDidFinish:(FeedDownload*)sender;
@end

View File

@@ -0,0 +1,223 @@
//
// 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 RSXML;
#import "FeedDownload.h"
#import "FaviconDownload.h"
#import "Feed+Ext.h"
#import "FeedMeta+Ext.h"
#import "NSURLRequest+Ext.h"
@interface FeedDownload()
@property (nonatomic, assign) BOOL respondToSelectFeed, respondToRedirect, respondToEnd;
@property (nonatomic, weak) id<FeedDownloadDelegate> delegate;
@property (nonatomic, strong) FeedDownloadBlock block;
@property (nonatomic, weak) NSURLSessionTask *currentDownload;
@property (nonatomic, assign) BOOL canceled;
@property (nonatomic, assign) BOOL assertIsFeedURL; // prohibit processing of HTML data
@property (nonatomic, strong) NSURLRequest *request;
@property (nonatomic, strong) NSHTTPURLResponse* response;
@property (nonatomic, strong) RSParsedFeed *xmlfeed;
@property (nonatomic, strong) NSError *error;
@property (nonatomic, strong) NSString *faviconURL;
@end
@implementation FeedDownload
// ---------------------------------------------------------------
// | MARK: - Class methods
// ---------------------------------------------------------------
/// @return New instance with plain @c url request.
+ (instancetype)withURL:(NSString*)url {
FeedDownload *this = [FeedDownload new];
this.request = [NSURLRequest withURL:url];
return this;
}
/// @return New instance using existing @c feed as template. Will reuse @c Etag and @c Last-modified headers.
+ (instancetype)withFeed:(Feed*)feed forced:(BOOL)flag {
FeedMeta *m = feed.meta;
NSMutableURLRequest *req = [NSMutableURLRequest withURL:m.url];
if (!flag) // any request that is not forced, is a background update
req.networkServiceType = NSURLNetworkServiceTypeBackground;
if (feed.articles.count > 0) { // dont use cache if feed is broken
// Both fields should be send (if server provides both) RFC: https://tools.ietf.org/html/rfc7232#section-2.4
if (m.etag.length > 0)
[req setValue:[m.etag stringByReplacingOccurrencesOfString:@"-gzip" withString:@""] forHTTPHeaderField:@"If-None-Match"]; // ETag
if (m.modified.length > 0)
[req setValue:m.modified forHTTPHeaderField:@"If-Modified-Since"];
}
FeedDownload *this = [FeedDownload new];
this.assertIsFeedURL = YES;
this.request = req;
return this;
}
// ---------------------------------------------------------------
// | MARK: - Getter & Setter
// ---------------------------------------------------------------
/// Set delegate and check what methods are implemented.
- (void)setDelegate:(id<FeedDownloadDelegate>)observer {
_delegate = observer;
_respondToSelectFeed = [observer respondsToSelector:@selector(feedDownload:selectFeedFromList:)];
_respondToRedirect = [observer respondsToSelector:@selector(feedDownload:urlRedirected:)];
_respondToEnd = [observer respondsToSelector:@selector(feedDownloadDidFinish:)];
}
/// @return Initialize @c FaviconDownload instance. Will reuse favicon url from HTML parsing.
- (FaviconDownload*)faviconDownload {
if (self.faviconURL.length > 0) // favicon url already found, nice job
return [FaviconDownload withURL:self.faviconURL isImageURL:YES];
NSString *url = self.xmlfeed.link; // does only work for status != 304
if (!url) url = self.response.URL.absoluteString;
return [FaviconDownload withURL:url isImageURL:NO];
}
// ---------------------------------------------------------------
// | MARK: - Actions
// ---------------------------------------------------------------
/// Start download request and use @c delegate as callback notifier.
- (instancetype)startWithDelegate:(id<FeedDownloadDelegate>)delegate {
self.delegate = delegate;
[self downloadSource:self.request];
return self;
}
/// Start download request and use @c block as callback notifier.
- (instancetype)startWithBlock:(nonnull FeedDownloadBlock)block {
self.block = block;
[self downloadSource:self.request];
return self;
}
/// Cancel running download task without notice. Will notify neither @c delegate nor @c block
- (void)cancel {
self.canceled = YES;
self.delegate = nil;
self.block = nil;
[self.currentDownload cancel];
}
/// Take the @c urlStr and run a download @c dataTask: on it. Auto-detect if data is HTML or feed.
- (void)downloadSource:(NSURLRequest*)request {
self.currentDownload = [request dataTask:^(NSData * _Nullable data, NSError * _Nullable error, NSHTTPURLResponse *response) {
self.error = error;
self.response = response;
if (!data) { // data = nil if (error || 304)
[self performSelectorOnMainThread:@selector(finishAndNotify) withObject:nil waitUntilDone:NO];
return;
}
RSXMLData *xml = [[RSXMLData alloc] initWithData:data url:response.URL];
if (!self.assertIsFeedURL && [xml.parserClass isHTMLParser])
[self processXMLDataHTML:xml];
else
[self processXMLDataFeed:xml];
}];
}
/// The downloaded source seems to be HTML data, lets parse it with @c RSXML @c RSHTMLMetadataParser
- (void)processXMLDataHTML:(RSXMLData*)xml {
RSHTMLMetadataParser *parser = [RSHTMLMetadataParser parserWithXMLData:xml];
[parser parseAsync:^(RSHTMLMetadata * _Nullable meta, NSError * _Nullable error) {
if (error) {
self.error = error;
} else if (!meta || meta.feedLinks.count == 0) {
self.error = RSXMLMakeErrorWrongParser(RSXMLErrorExpectingFeed, RSXMLErrorExpectingHTML, xml.url);
} else {
self.faviconURL = [FaviconDownload urlForMetadata:meta]; // we can re-use favicon url if we find one
NSString *chosenURL = meta.feedLinks.firstObject.link;
if (self.respondToSelectFeed && meta.feedLinks.count > 1)
chosenURL = [self.delegate feedDownload:self selectFeedFromList:meta.feedLinks];
if (chosenURL.length > 0) {
self.assertIsFeedURL = YES;
// Feeds like https://news.ycombinator.com/ return 503 if URLs are requested too rapidly
//CFRunLoopRunInMode(kCFRunLoopDefaultMode, 1.0, false); // Non-blocking sleep (1s)
[self downloadSource:[NSURLRequest withURL:chosenURL]];
return;
} else { // User canceled operation, show appropriate error message
NSDictionary *info = @{ NSLocalizedDescriptionKey: NSLocalizedString(@"Operation canceled.", nil) };
self.error = [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorCancelled userInfo:info];
}
}
[self finishAndNotify];
}];
}
/// The downloaded source seems to be proper feed data, lets parse it with @c RSXML @c RSFeedParser
- (void)processXMLDataFeed:(RSXMLData*)xml {
RSFeedParser *parser = [RSFeedParser parserWithXMLData:xml];
parser.dontStopOnLowerAsciiBytes = YES;
[parser parseAsync:^(RSParsedFeed * _Nullable parsedDocument, NSError * _Nullable error) {
self.error = error;
self.xmlfeed = parsedDocument;
[self finishAndNotify];
}];
}
/// Check if @c responseURL @c != @c requestURL
- (void)checkRedirectAndNotify {
NSString *responseURL = self.response.URL.absoluteString;
if (responseURL.length > 0 && ![responseURL isEqualToString:self.request.URL.absoluteString]) {
if (self.respondToRedirect) [self.delegate feedDownload:self urlRedirected:responseURL];
}
}
/// Called when feed download finished or failed, but not if canceled. Will notify @c delegate .
- (void)finishAndNotify {
if (self.canceled)
return;
[self checkRedirectAndNotify];
// notify observer
if (self.respondToEnd) [self.delegate feedDownloadDidFinish:self];
if (self.block) { self.block(self); self.block = nil; }
}
/**
Persist in memory object by copying all attributes to permanent core data storage.
@param flag If @c YES then @c FeedGroup won't increase the error count for the feed.
Feed will be scheduled as soon as the user reconnects to the internet.
@return @c YES if downloaded feed contains at least one article. ( @c 304 returns @c NO )
*/
- (BOOL)copyValuesTo:(nonnull Feed*)feed ignoreError:(BOOL)flag {
if (!flag && self.error) // Increase error count and schedule next update.
[feed.meta setErrorAndPostponeSchedule];
else if (self.response) // Update Etag & Last modified and schedule next update.
[feed.meta setSucessfulWithResponse:self.response];
else // Update URL but keep schedule (e.g., error while adding feed should auto-try once reconnected)
[feed.meta setUrlIfChanged:self.request.URL.absoluteString];
// If feed is broken indicate that feed will not be updated
if (!self.xmlfeed || self.xmlfeed.articles.count == 0)
return NO;
// Else: Update stored articles and indicate that feed was updated
[feed updateWithRSS:self.xmlfeed postUnreadCountChange:YES];
return YES;
}
@end

View File

@@ -32,7 +32,7 @@
#pragma mark - Helper
/// Loop over all subviews and find the @c NSButton that is selected.
NS_INLINE NSInteger RadioGroupSelection(NSView *view) {
static NSInteger RadioGroupSelection(NSView *view) {
for (NSButton *btn in view.subviews) {
if ([btn isKindOfClass:[NSButton class]] && btn.state == NSControlStateValueOn) {
return btn.tag;
@@ -93,8 +93,7 @@ NS_INLINE NSInteger RadioGroupSelection(NSView *view) {
- (void)enumerateFiles:(NSArray<NSURL*>*)files withBlock:(void(^)(RSOPMLItem *item))block finally:(nullable dispatch_block_t)finally {
dispatch_group_t group = dispatch_group_create();
for (NSURL *url in files) {
if (finally) dispatch_group_enter(group);
dispatch_group_enter(group);
NSData *data = [NSData dataWithContentsOfURL:url];
RSXMLData *xml = [[RSXMLData alloc] initWithData:data url:url];
RSOPMLParser *parser = [RSOPMLParser parserWithXMLData:xml];
@@ -106,7 +105,7 @@ NS_INLINE NSInteger RadioGroupSelection(NSView *view) {
block(itm);
}
}
if (finally) dispatch_group_leave(group);
dispatch_group_leave(group);
}];
}
if (finally) dispatch_group_notify(group, dispatch_get_main_queue(), finally);
@@ -251,9 +250,7 @@ NS_INLINE NSInteger RadioGroupSelection(NSView *view) {
NSData *xml = [doc XMLDataWithOptions:NSXMLNodePreserveAttributeOrder | NSXMLNodePrettyPrint];
[xml writeToURL:url options:NSDataWritingAtomic error:&error];
}
if (error) {
[NSApp presentError:error];
}
if (error) [NSApp presentError:error];
return error;
}
@@ -293,8 +290,8 @@ NS_INLINE NSInteger RadioGroupSelection(NSView *view) {
// dont add group node if hierarchical == NO
NSXMLElement *outline = [NSXMLElement elementWithName:@"outline"];
[parent addChild:outline];
[outline addAttribute:[NSXMLNode attributeWithName:OPMLTitleKey stringValue:item.name]];
[outline addAttribute:[NSXMLNode attributeWithName:OPMLTextKey stringValue:item.name]];
[outline addAttribute:[NSXMLNode attributeWithName:OPMLTitleKey stringValue:item.anyName]];
[outline addAttribute:[NSXMLNode attributeWithName:OPMLTextKey stringValue:item.anyName]];
if (item.type == SEPARATOR) {
[outline addAttribute:[NSXMLNode attributeWithName:@"separator" stringValue:@"true"]]; // baRSS specific

View File

@@ -37,7 +37,11 @@
// Scheduling
+ (void)scheduleNextFeed;
+ (void)forceUpdateAllFeeds;
+ (void)downloadList:(NSArray<Feed*>*)list background:(BOOL)flag finally:(nullable os_block_t)block;
+ (void)downloadList:(NSArray<Feed*>*)list userInitiated:(BOOL)flag finally:(nullable os_block_t)block;
+ (void)updateAllFavicons;
// Auto Download & Parse Feed URL
+ (void)autoDownloadAndParseURL:(NSString*)url;
+ (void)autoDownloadAndParseUpdateURL;
// Register for network change notifications
+ (void)registerNetworkChangeNotification;
+ (void)unregisterNetworkChangeNotification;

View File

@@ -22,24 +22,33 @@
@import SystemConfiguration;
#import "UpdateScheduler.h"
#import "WebFeed.h"
#import "Constants.h"
#import "StoreCoordinator.h"
#import "NSDate+Ext.h"
#import "FeedDownload.h"
#import "FaviconDownload.h"
#import "Feed+Ext.h"
#import "FeedMeta+Ext.h"
#import "FeedGroup+Ext.h"
#include <stdatomic.h>
static NSTimer *_timer;
static SCNetworkReachabilityRef _reachability = NULL;
static BOOL _isReachable = YES;
static BOOL _updatePaused = NO;
static BOOL _nextUpdateIsForced = NO;
static _Atomic(NSUInteger) _queueSize = 0;
@implementation UpdateScheduler
#pragma mark - User Interaction
// ################################################################
// # MARK: - Getter & Setter -
// ################################################################
/// @return Number of feeds being currently downloaded.
+ (NSUInteger)feedsInQueue { return [WebFeed feedsInQueue]; }
+ (NSUInteger)feedsInQueue { return _queueSize; }
/// @return Date when background update will fire. If updates are paused, date is @c distantFuture.
+ (NSDate *)dateScheduled { return _timer.fireDate; }
@@ -48,7 +57,7 @@ static BOOL _nextUpdateIsForced = NO;
+ (BOOL)allowNetworkConnection { return (_isReachable && !_updatePaused); }
/// @return @c YES if batch update is running
+ (BOOL)isUpdating { return [WebFeed feedsInQueue] > 0; }
+ (BOOL)isUpdating { return _queueSize > 0; }
/// @return @c YES if update is paused by user.
+ (BOOL)isPaused { return _updatePaused; }
@@ -78,7 +87,7 @@ static BOOL _nextUpdateIsForced = NO;
/// Update status. 'Updating X feeds ' or empty string if not updating.
+ (NSString*)updatingXFeeds {
NSUInteger c = [WebFeed feedsInQueue];
NSUInteger c = _queueSize;
switch (c) {
case 0: return @"";
case 1: return NSLocalizedString(@"Updating 1 feed …", nil);
@@ -86,17 +95,15 @@ static BOOL _nextUpdateIsForced = NO;
}
}
// ################################################################
// # MARK: - Schedule Timer Actions -
// ################################################################
#pragma mark - Update Feed Timer
/**
Get date of next up feed and start the timer.
*/
/// Get date of next up feed and start the timer.
+ (void)scheduleNextFeed {
if (![self allowNetworkConnection]) // timer will restart once connection exists
return;
if ([WebFeed feedsInQueue] > 0) // assume every update ends with scheduleNextFeed
if (_queueSize > 0) // assume every update ends with scheduleNextFeed
return; // skip until called again
NSDate *nextTime = [StoreCoordinator nextScheduledUpdate]; // if nextTime = nil, then no feeds to update
if (nextTime && [nextTime timeIntervalSinceNow] < 1) { // mostly, if app was closed for a long time
@@ -105,9 +112,7 @@ static BOOL _nextUpdateIsForced = NO;
[self scheduleTimer:nextTime];
}
/**
Start download of all feeds (immediatelly) regardless of @c .scheduled property.
*/
/// Start download of all feeds (immediatelly) regardless of @c .scheduled property.
+ (void)forceUpdateAllFeeds {
if (![self allowNetworkConnection]) // timer will restart once connection exists
return;
@@ -118,7 +123,7 @@ static BOOL _nextUpdateIsForced = NO;
/**
Set new @c .fireDate and @c .tolerance for update timer.
@param nextTime If @c nil timer will be disabled with a @c .fireDate very far in the future.
@param nextTime If @c nil disable timer and set @c .fireDate to distant future.
*/
+ (void)scheduleTimer:(NSDate*)nextTime {
static dispatch_once_t onceToken;
@@ -134,9 +139,7 @@ static BOOL _nextUpdateIsForced = NO;
PostNotification(kNotificationScheduleTimerChanged, nil);
}
/**
Called when schedule timer runs out (earliest @c .schedule date). Or if forced by user request.
*/
/// Called when schedule timer runs out (earliest @c .schedule date). Or if forced by user.
+ (void)updateTimerCallback {
#ifdef DEBUG
NSLog(@"fired");
@@ -145,35 +148,115 @@ static BOOL _nextUpdateIsForced = NO;
_nextUpdateIsForced = NO;
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
NSArray<Feed*> *list = [StoreCoordinator getListOfFeedsThatNeedUpdate:updateAll inContext:moc];
NSArray<Feed*> *list = [StoreCoordinator listOfFeedsThatNeedUpdate:updateAll inContext:moc];
//NSAssert(list.count > 0, @"ERROR: Something went wrong, timer fired too early.");
[self downloadList:list background:!updateAll finally:^{
[self downloadList:list userInitiated:updateAll finally:^{
[StoreCoordinator saveContext:moc andParent:YES]; // save parents too ...
[moc reset];
[self scheduleNextFeed]; // always reset the timer
}];
}
/// Download list of feeds. Either silently in background or in foreground with alerts.
+ (void)downloadList:(NSArray<Feed*>*)list background:(BOOL)flag finally:(nullable os_block_t)block {
// ################################################################
// # MARK: - Download Actions -
// ################################################################
/// Perform @c FaviconDownload on all core data @c Feed entries.
+ (void)updateAllFavicons {
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
for (Feed *f in [StoreCoordinator listOfFeedsThatNeedUpdate:YES inContext:moc])
[FaviconDownload updateFeed:f finally:nil];
[moc reset];
}
/// Download list of feeds. Either silently in background or with alerts in foreground.
+ (void)downloadList:(NSArray<Feed*>*)list userInitiated:(BOOL)flag finally:(nullable os_block_t)block {
if (![self allowNetworkConnection]) {
if (block) block();
} else if (flag) {
[WebFeed batchDownloadFeeds:list showErrorAlert:NO finally:block];
} else {
// TODO: add undo grouping?
[WebFeed setRequestsAreUrgent:YES];
[WebFeed batchDownloadFeeds:list showErrorAlert:YES finally:^{
[WebFeed setRequestsAreUrgent:NO];
if (block) block();
return;
}
// Else: batch download
atomic_fetch_add_explicit(&_queueSize, list.count, memory_order_relaxed);
PostNotification(kNotificationBackgroundUpdateInProgress, @(_queueSize));
dispatch_group_t group = dispatch_group_create();
for (Feed *f in list) {
dispatch_group_enter(group);
[self updateFeed:f alert:flag isForced:flag finally:^{
atomic_fetch_sub_explicit(&_queueSize, 1, memory_order_relaxed);
PostNotification(kNotificationBackgroundUpdateInProgress, @(_queueSize));
dispatch_group_leave(group);
}];
}
if (block) dispatch_group_notify(group, dispatch_get_main_queue(), block);
}
/// Helper method to show modal error alert
static inline void AlertDownloadError(NSError *err, NSString *url) {
NSAlert *alertPopup = [NSAlert alertWithError:err];
alertPopup.informativeText = [NSString stringWithFormat:@"Error loading source: %@", url];
[alertPopup runModal];
}
#pragma mark - Network Connection & Reachability
/**
Start download request with existing @c Feed object. Reuses etag and modified headers (unless articles count is 0).
@note Will post a @c kNotificationArticlesUpdated notification if download was successful and status code is @b not 304.
*/
+ (void)updateFeed:(Feed*)feed alert:(BOOL)alert isForced:(BOOL)forced finally:(nullable os_block_t)block {
NSManagedObjectContext *moc = feed.managedObjectContext;
NSManagedObjectID *oid = feed.objectID;
[[FeedDownload withFeed:feed forced:forced] startWithBlock:^(FeedDownload *mem) {
if (alert && mem.error) // but still copy values for error count increment
AlertDownloadError(mem.error, mem.request.URL.absoluteString);
Feed *f = [moc objectWithID:oid];
BOOL recentlyAdded = (f.articles.count == 0); // before copy values
BOOL downloadIcon = (!f.hasIcon && (recentlyAdded || forced));
BOOL needsNotification = [mem copyValuesTo:f ignoreError:NO];
[StoreCoordinator saveContext:moc andParent:YES];
if (needsNotification)
PostNotification(kNotificationArticlesUpdated, oid);
if (downloadIcon && !mem.error) {
[FaviconDownload updateFeed:f finally:block];
} else if (block) block(); // always call block(); with or without favicon download
}];
}
/**
Download feed at url and append to persistent store in root folder. On error present user modal alert.
Creates new @c FeedGroup, @c Feed, @c FeedMeta and @c FeedArticle instances and saves them to the persistent store.
*/
+ (void)autoDownloadAndParseURL:(NSString*)url addAnyway:(BOOL)flag name:(nullable NSString*)title refresh:(int32_t)interval {
[[FeedDownload withURL:url] startWithBlock:^(FeedDownload *mem) {
if (!flag && mem.error) {
AlertDownloadError(mem.error, url);
return;
}
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
FeedGroup *fg = [FeedGroup appendToRoot:FEED inContext:moc];
[fg setNameIfChanged:title];
[fg.feed.meta setRefreshIfChanged:interval];
[mem copyValuesTo:fg.feed ignoreError:YES];
[StoreCoordinator saveContext:moc andParent:YES];
PostNotification(kNotificationFeedGroupInserted, fg.objectID);
if (!mem.error) [FaviconDownload updateFeed:fg.feed finally:nil];
[moc reset];
[UpdateScheduler scheduleNextFeed];
}];
}
/// Download and process feed url. Auto update feed title with an update interval of 30 min.
+ (void)autoDownloadAndParseURL:(NSString*)url {
[self autoDownloadAndParseURL:url addAnyway:NO name:nil refresh:kDefaultFeedRefreshInterval];
}
/// Insert Github URL for version releases with update interval 2 days and rename @c FeedGroup item.
+ (void)autoDownloadAndParseUpdateURL {
[self autoDownloadAndParseURL:versionUpdateURL addAnyway:YES name:NSLocalizedString(@"baRSS releases", nil) refresh:2 * TimeUnitDays];
}
// ################################################################
// # MARK: - Network Connection & Reachability -
// ################################################################
/// Set callback on @c self to listen for network reachability changes.
+ (void)registerNetworkChangeNotification {

View File

@@ -1,52 +0,0 @@
//
// The MIT License (MIT)
// Copyright (c) 2018 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
@import RSXML;
@class Feed;
@interface WebFeed : NSObject
@property (class, readonly) NSUInteger feedsInQueue;
+ (void)setRequestsAreUrgent:(BOOL)flag;
// Downloading
+ (void)newFeed:(NSString *)urlStr askUser:(nonnull NSString*(^)(RSHTMLMetadata *meta))askUser block:(nonnull void(^)(RSParsedFeed *parsed, NSError *error, NSHTTPURLResponse *response))block;
+ (void)autoDownloadAndParseURL:(NSString*)urlStr addAnyway:(BOOL)flag modify:(nullable void(^)(Feed *feed))block;
+ (void)autoDownloadAndParseUpdateURL;
+ (void)batchDownloadFeeds:(NSArray<Feed*>*)list showErrorAlert:(BOOL)alert finally:(nullable os_block_t)block;
// Favicon image download
+ (void)downloadFavicon:(NSString*)urlStr finished:(void(^)(NSImage * _Nullable img))block;
+ (void)downloadImage:(NSString*)url finished:(void(^)(NSImage * _Nullable img))block;
+ (nullable NSString*)faviconUrlForMetadata:(RSHTMLMetadata*)meta;
@end
/*
Developer Tip, error logs see:
Task <..> HTTP load failed (error code: -1003 [12:8])
Task <..> finished with error - code: -1003
==> NSURLErrorCannotFindHost in #import <Foundation/NSURLError.h>
TIC TCP Conn Failed [21:0x1d417fb00]: 1:65 Err(65)
==> EHOSTUNREACH in #import <sys/errno.h>
*/

View File

@@ -1,409 +0,0 @@
//
// The MIT License (MIT)
// Copyright (c) 2018 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 "WebFeed.h"
#import "UpdateScheduler.h"
#import "Constants.h"
#import "StoreCoordinator.h"
#import "Feed+Ext.h"
#import "FeedMeta+Ext.h"
#import "FeedGroup+Ext.h"
#import "NSDate+Ext.h"
#import "NSString+Ext.h"
#include <stdatomic.h>
static BOOL _requestsAreUrgent = NO;
static _Atomic(NSUInteger) _queueSize = 0;
@implementation WebFeed
/// Disables @c NSURLNetworkServiceTypeBackground (ideally only temporarily)
+ (void)setRequestsAreUrgent:(BOOL)flag { _requestsAreUrgent = flag; }
/// @return Number of feeds being currently downloaded.
+ (NSUInteger)feedsInQueue { return _queueSize; }
#pragma mark - Request Generator
/// @return Base URL part. E.g., https://stackoverflow.com/a/15897956/10616114 ==> https://stackoverflow.com/
+ (NSURL*)hostURL:(NSString*)urlStr {
return [[NSURL URLWithString:@"/" relativeToURL:[self fixURL:urlStr]] absoluteURL];
}
/// Check if any scheme is set. If not, prepend 'http://'.
+ (NSURL*)fixURL:(NSString*)urlStr {
NSURL *url = [NSURL URLWithString:urlStr];
if (!url.scheme) {
url = [NSURL URLWithString:[NSString stringWithFormat:@"http://%@", urlStr]]; // usually will redirect to https if necessary
}
return url;
}
/// @return New request with no caching policy and timeout interval of 30 seconds.
+ (NSMutableURLRequest*)newRequestURL:(NSString*)urlStr {
return [NSMutableURLRequest requestWithURL:[self fixURL:urlStr]];
}
/// @return New request with etag and modified headers set (or not, if @c flag @c == @c YES ).
+ (NSURLRequest*)newRequest:(FeedMeta*)meta ignoreCache:(BOOL)flag {
NSMutableURLRequest *req = [self newRequestURL:meta.url];
if (!flag) {
// Both fields should be sent (if server provides both) RFC: https://tools.ietf.org/html/rfc7232#section-2.4
if (meta.etag.length > 0) {
NSString *etag = [meta.etag stringByReplacingOccurrencesOfString:@"-gzip" withString:@""];
[req setValue:etag forHTTPHeaderField:@"If-None-Match"]; // ETag
}
if (meta.modified.length > 0)
[req setValue:meta.modified forHTTPHeaderField:@"If-Modified-Since"];
}
if (!_requestsAreUrgent) // any request that is not forced, is a background update
req.networkServiceType = NSURLNetworkServiceTypeBackground;
return req;
}
+ (NSURLSession*)nonCachingSession {
static NSURLSession *session = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSURLSessionConfiguration *conf = [NSURLSessionConfiguration defaultSessionConfiguration];
conf.HTTPCookieAcceptPolicy = NSHTTPCookieAcceptPolicyNever;
conf.HTTPShouldSetCookies = NO;
conf.HTTPCookieStorage = nil; // disables '~/Library/Cookies/'
conf.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
conf.URLCache = nil; // disables '~/Library/Caches/de.relikd.baRSS/'
conf.HTTPAdditionalHeaders = @{ @"User-Agent": @"baRSS (macOS)",
@"Accept-Encoding": @"gzip" };
session = [NSURLSession sessionWithConfiguration:conf];
});
return session; // [NSURLSession sharedSession];
}
/// Helper method to start new @c NSURLSession. If @c (http.statusCode==304) then set @c data @c = @c nil.
+ (void)asyncRequest:(NSURLRequest*)request block:(nonnull void(^)(NSData * _Nullable data, NSError * _Nullable error, NSHTTPURLResponse *response))block {
[[[self nonCachingSession] dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
NSHTTPURLResponse* httpResponse = (NSHTTPURLResponse*)response;
NSInteger status = [httpResponse statusCode];
if (error || status == 304) { // 304 Not Modified
data = nil;
} else if (status >= 500 && status < 600) { // 5xx Server Error
NSString *reason = [NSString stringWithFormat:NSLocalizedString(@"Server HTTP error %ld.\n\n%@", nil),
status, [NSString plainTextFromHTMLData:data]];
error = [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorBadServerResponse userInfo:@{NSLocalizedDescriptionKey: reason}];
data = nil;
}
block(data, error, httpResponse); // if status == 304, data & error nil
}] resume];
}
#pragma mark - Download RSS Feed
/**
Start download session of RSS or Atom feed, parse feed and return result on the main thread.
@param xmlBlock Called immediately after @c RSXMLData is initialized. E.g., to use this data as HTML parser.
Return @c YES to to exit without calling @c feedBlock.
If @c NO and @c err @c != @c nil skip feed parsing and call @c feedBlock(nil,err,response).
@param feedBlock Called when parsing finished or an @c NSURL error occured.
If content did not change (status code 304) both, error and result will be @c nil.
Will be called on main thread.
*/
+ (void)parseFeedRequest:(NSURLRequest*)request xmlBlock:(nullable BOOL(^)(RSXMLData *xml, NSError **err))xmlBlock feedBlock:(nonnull void(^)(RSParsedFeed *rss, NSError *error, NSHTTPURLResponse *response))feedBlock {
[self asyncRequest:request block:^(NSData * _Nullable data, NSError * _Nullable error, NSHTTPURLResponse *response) {
RSParsedFeed *result = nil;
if (data) { // data = nil if (error || 304)
RSXMLData *xml = [[RSXMLData alloc] initWithData:data url:response.URL];
if (xmlBlock && xmlBlock(xml, &error)) {
return;
}
if (!error) { // metaBlock may set error
RSFeedParser *parser = [RSFeedParser parserWithXMLData:xml];
parser.dontStopOnLowerAsciiBytes = YES;
result = [parser parseSync:&error];
}
}
dispatch_async(dispatch_get_main_queue(), ^{
feedBlock(result, error, response);
});
}];
}
/**
Perform feed download request from URL alone. Not updating any @c Feed item.
@note @c askUser will not be called if url is XML already.
@param urlStr XML URL or HTTP URL that will be parsed to find feed URLs.
@param askUser Use @c list to present user a list of detected feed URLs.
@param block Called after webpage has been fully parsed (including html autodetect).
*/
+ (void)newFeed:(NSString *)urlStr askUser:(nonnull NSString*(^)(RSHTMLMetadata *meta))askUser block:(nonnull void(^)(RSParsedFeed *parsed, NSError *error, NSHTTPURLResponse *response))block {
[self parseFeedRequest:[self newRequestURL:urlStr] xmlBlock:^BOOL(RSXMLData *xml, NSError **err) {
if (![xml.parserClass isHTMLParser])
return NO;
RSHTMLMetadataParser *parser = [RSHTMLMetadataParser parserWithXMLData:xml];
RSHTMLMetadata *parsedMeta = [parser parseSync:err];
if (*err)
return NO;
if (!parsedMeta || parsedMeta.feedLinks.count == 0) {
*err = RSXMLMakeErrorWrongParser(RSXMLErrorExpectingFeed, RSXMLErrorExpectingHTML, xml.url);
return NO;
}
__block NSString *chosenURL = nil;
dispatch_sync(dispatch_get_main_queue(), ^{ // sync! (thread is already in background)
chosenURL = askUser(parsedMeta);
});
if (!chosenURL || chosenURL.length == 0) { // User canceled operation, show appropriate error message
NSString *reason = NSLocalizedString(@"Operation canceled.", nil);
*err = [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorCancelled userInfo:@{NSLocalizedDescriptionKey: reason}];
return NO;
}
[self parseFeedRequest:[self newRequestURL:chosenURL] xmlBlock:nil feedBlock:block];
return YES;
} feedBlock:block];
}
/**
Start download request with existing @c Feed object. Reuses etag and modified headers (unless articles count is 0).
@note Will post a @c kNotificationFeedUpdated notification if download was successful and @b not status code 304.
@param alert If @c YES display Error Popup to user.
@param block Parameter @c success is only @c YES if download was successful or if status code is 304 (not modified).
*/
+ (void)backgroundUpdateFeed:(Feed*)feed showErrorAlert:(BOOL)alert finally:(nullable void(^)(BOOL success))block {
NSManagedObjectID *oid = feed.objectID;
NSManagedObjectContext *moc = feed.managedObjectContext;
NSURLRequest *req = [self newRequest:feed.meta ignoreCache:(feed.articles.count == 0)];
NSString *reqURL = req.URL.absoluteString;
[self parseFeedRequest:req xmlBlock:nil feedBlock:^(RSParsedFeed *rss, NSError *error, NSHTTPURLResponse *response) {
Feed *f = [moc objectWithID:oid];
BOOL success = NO;
BOOL needsNotification = NO;
if (error) {
if (alert) {
NSAlert *alertPopup = [NSAlert alertWithError:error];
alertPopup.informativeText = [NSString stringWithFormat:@"Error loading source: %@", reqURL];
[alertPopup runModal];
}
[f.meta setErrorAndPostponeSchedule];
} else {
success = YES;
[f.meta setSucessfulWithResponse:response];
if (rss && rss.articles.count > 0) {
[f updateWithRSS:rss postUnreadCountChange:YES];
needsNotification = YES;
}
}
[StoreCoordinator saveContext:moc andParent:YES];
if (needsNotification)
PostNotification(kNotificationFeedUpdated, oid);
if (block) block(success);
}];
}
/**
Download feed at url and append to persistent store in root folder.
On error present user modal alert.
Creates new @c FeedGroup, @c Feed, @c FeedMeta and @c FeedArticle instances and saves them to the persistent store.
Update duration is set to the default of 30 minutes.
*/
+ (void)autoDownloadAndParseURL:(NSString*)url addAnyway:(BOOL)flag modify:(nullable void(^)(Feed *feed))block {
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
Feed *f = [Feed appendToRootWithDefaultIntervalInContext:moc];
f.meta.url = url;
if (block) block(f);
[StoreCoordinator saveContext:moc andParent:YES];
[UpdateScheduler downloadList:@[f] background:flag finally:^{
PostNotification(kNotificationGroupInserted, f.group.objectID);
[moc reset];
[UpdateScheduler scheduleNextFeed];
}];
}
/// Insert Github URL for version releases with update interval 2 days and rename @c FeedGroup item.
+ (void)autoDownloadAndParseUpdateURL {
[self autoDownloadAndParseURL:versionUpdateURL addAnyway:YES modify:^(Feed *feed) {
feed.group.name = NSLocalizedString(@"baRSS releases", nil);
feed.meta.refresh = 2 * TimeUnitDays;
}];
}
/**
Start download of feed xml, then continue with favicon (if newly added or 'Update all').
@param alert If @c YES display Error Popup to user.
@param block Parameter @c success is @c YES if xml download succeeded (regardless of favicon result).
*/
+ (void)backgroundUpdateBoth:(Feed*)feed alert:(BOOL)alert finally:(nullable void(^)(BOOL success))block {
BOOL recentlyAdded = (feed.articles.count == 0);
[self backgroundUpdateFeed:feed showErrorAlert:alert finally:^(BOOL success) {
if (success && (recentlyAdded || _requestsAreUrgent)) {
[self backgroundUpdateFavicon:feed replaceExisting:NO finally:^{
if (block) block(YES);
}];
} else {
if (block) block(success);
}
}];
}
/**
Start download of all feeds in list. Favicons will be loaded for new feeds and for 'Update all'.
@param list Download list using @c feed.meta.url as download url. (while reusing etag and modified headers)
@param alert If @c YES display Error Popup to user.
@param block Called after all downloads finished.
*/
+ (void)batchDownloadFeeds:(NSArray<Feed*>*)list showErrorAlert:(BOOL)alert finally:(nullable os_block_t)block {
atomic_fetch_add_explicit(&_queueSize, list.count, memory_order_relaxed);
PostNotification(kNotificationBackgroundUpdateInProgress, @(_queueSize));
dispatch_group_t group = dispatch_group_create();
for (Feed *f in list) {
dispatch_group_enter(group);
[self backgroundUpdateBoth:f alert:alert finally:^(BOOL success){
atomic_fetch_sub_explicit(&_queueSize, 1, memory_order_relaxed);
PostNotification(kNotificationBackgroundUpdateInProgress, @(_queueSize));
dispatch_group_leave(group);
}];
}
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
if (block) block();
});
}
#pragma mark - Download Favicon
/**
Start favicon download request on existing @c Feed object.
@note Will post a @c kNotificationFeedIconUpdated notification if icon was updated.
@param overwrite If @c YES and icon is present already, @c block will return immediatelly.
*/
+ (void)backgroundUpdateFavicon:(Feed*)feed replaceExisting:(BOOL)overwrite finally:(nullable os_block_t)block {
if (!overwrite && feed.icon != nil) {
if (block) block();
return; // skip existing icons if replace == NO
}
NSManagedObjectID *oid = feed.objectID;
NSManagedObjectContext *moc = feed.managedObjectContext;
NSString *faviconURL = (feed.link.length > 0 ? feed.link : feed.meta.url);
[self downloadFavicon:faviconURL finished:^(NSImage *img) {
Feed *f = [moc objectWithID:oid];
if (f && [f setIconImage:img]) {
[StoreCoordinator saveContext:moc andParent:YES];
PostNotification(kNotificationFeedIconUpdated, oid);
}
if (block) block();
}];
}
/// Download favicon located at http://.../ @c favicon.ico. Callback @c block will be called on main thread.
+ (void)downloadFavicon:(NSString*)urlStr finished:(void(^)(NSImage * _Nullable img))block {
NSURL *host = [self hostURL:urlStr];
NSString *hostURL = host.absoluteString;
NSString *favURL = [host URLByAppendingPathComponent:@"favicon.ico"].absoluteString;
[self downloadImage:favURL finished:^(NSImage * _Nullable img) {
if (img) {
block(img); // is on main already (from downloadImage:)
} else {
[self downloadFaviconByParsingHTML:hostURL finished:block];
}
}];
}
/// Download html page and parse all icon urls. Starting a successive request on the favicon url.
+ (void)downloadFaviconByParsingHTML:(NSString*)hostURL finished:(void(^)(NSImage * _Nullable img))block {
[self asyncRequest:[self newRequestURL:hostURL] block:^(NSData * _Nullable htmlData, NSError * _Nullable error, NSHTTPURLResponse *response) {
if (htmlData) {
// TODO: use session delegate to stop downloading after <head>
RSXMLData *xml = [[RSXMLData alloc] initWithData:htmlData url:response.URL];
RSHTMLMetadataParser *parser = [RSHTMLMetadataParser parserWithXMLData:xml];
RSHTMLMetadata *meta = [parser parseSync:&error];
if (error) meta = nil;
NSString *iconURL = [self faviconUrlForMetadata:meta];
if (iconURL) {
// if everything went well we can finally start a request on the url we found.
[self downloadImage:iconURL finished:block];
return;
}
}
dispatch_async(dispatch_get_main_queue(), ^{ block(nil); }); // on failure
}];
}
/// Extract favicon URL from parsed HTML metadata.
+ (nullable NSString*)faviconUrlForMetadata:(RSHTMLMetadata*)meta {
if (meta) {
if (meta.faviconLink.length > 0) {
return meta.faviconLink;
}
else if (meta.iconLinks.count > 0) {
// at least any url (even if all items in list have size 0)
NSString *iconURL = meta.iconLinks.firstObject.link;
double best = DBL_MAX;
for (RSHTMLMetadataIconLink *icon in meta.iconLinks) {
CGSize size = [icon getSize];
CGFloat area = size.width * size.height;
if (area > 0) {
// find icon with closest matching size 32x32
double match = fabs(log10(area) - log10(32*32));
if (match < best) {
best = match;
iconURL = icon.link;
}
}
}
if (iconURL && iconURL.length > 0)
return iconURL;
}
}
return nil;
}
/// Download image in a background thread and notify once finished.
+ (void)downloadImage:(NSString*)url finished:(void(^)(NSImage * _Nullable img))block {
[self asyncRequest:[self newRequestURL:url] block:^(NSData * _Nullable data, NSError * _Nullable e, NSHTTPURLResponse *r) {
NSImage *img = [[NSImage alloc] initWithData:data];
if (!img || ![img isValid])
img = nil;
// if (img.size.width > 16 || img.size.height > 16) {
// NSImage *smallImage = [NSImage imageWithSize:NSMakeSize(16, 16) flipped:NO drawingHandler:^BOOL(NSRect dstRect) {
// [img drawInRect:dstRect];
// return YES;
// }];
// if (img.TIFFRepresentation.length > smallImage.TIFFRepresentation.length)
// img = smallImage;
// }
dispatch_async(dispatch_get_main_queue(), ^{ block(img); });
}];
}
@end

View File

@@ -0,0 +1,27 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
@interface NSError (Ext)
+ (instancetype)statusCode:(NSInteger)code reason:(nullable NSString*)reason;
@end

113
baRSS/Helper/NSError+Ext.m Normal file
View File

@@ -0,0 +1,113 @@
//
// 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 "NSError+Ext.h"
@implementation NSError (Ext)
static const char* CodeDescription(NSInteger code) {
switch (code) {
/* --- Informational --- */
case 100: return "Continue";
case 101: return "Switching Protocols";
case 102: return "Processing";
case 103: return "Early Hints";
/* --- Success --- */
case 200: return "OK";
case 201: return "Created";
case 202: return "Accepted";
case 203: return "Non-Authoritative Information";
case 204: return "No Content";
case 205: return "Reset Content";
case 206: return "Partial Content";
case 207: return "Multi-Status";
case 208: return "Already Reported";
case 226: return "IM Used";
/* --- Redirection --- */
case 300: return "Multiple Choices";
case 301: return "Moved Permanently";
case 302: return "Found";
case 303: return "See Other";
case 304: return "Not Modified";
case 305: return "Use Proxy";
case 306: return "Switch Proxy";
case 307: return "Temporary Redirect";
case 308: return "Permanent Redirect";
/* --- Client error --- */
case 400: return "Bad Request";
case 401: return "Unauthorized";
case 402: return "Payment Required";
case 403: return "Forbidden";
case 404: return "Not Found";
case 405: return "Method Not Allowed";
case 406: return "Not Acceptable";
case 407: return "Proxy Authentication Required";
case 408: return "Request Timeout";
case 409: return "Conflict";
case 410: return "Gone";
case 411: return "Length Required";
case 412: return "Precondition Failed";
case 413: return "Payload Too Large";
case 414: return "URI Too Long";
case 415: return "Unsupported Media Type";
case 416: return "Range Not Satisfiable";
case 417: return "Expectation Failed";
case 418: return "I'm a teapot";
case 421: return "Misdirected Request";
case 422: return "Unprocessable Entity";
case 423: return "Locked";
case 424: return "Failed Dependency";
case 425: return "Too Early";
case 426: return "Upgrade Required";
case 428: return "Precondition Required";
case 429: return "Too Many Requests";
case 431: return "Request Header Fields Too Large";
case 451: return "Unavailable For Legal Reasons";
/* --- Server error --- */
case 500: return "Internal Server Error";
case 501: return "Not Implemented";
case 502: return "Bad Gateway";
case 503: return "Service Unavailable";
case 504: return "Gateway Timeout";
case 505: return "HTTP Version Not Supported";
case 506: return "Variant Also Negotiates";
case 507: return "Insufficient Storage";
case 508: return "Loop Detected";
case 510: return "Not Extended";
case 511: return "Network Authentication Required";
}
return "Unknown";
}
/// Generate @c NSError from HTTP status code. E.g., @c code @c = @c 404 will return "404 Not Found".
+ (instancetype)statusCode:(NSInteger)code reason:(nullable NSString*)reason {
NSMutableDictionary *info = [NSMutableDictionary dictionaryWithCapacity:2];
info[NSLocalizedDescriptionKey] = [NSString stringWithFormat:@"%ld %s.", code, CodeDescription(code)];
if (reason) info[NSLocalizedRecoverySuggestionErrorKey] = reason;
NSInteger errCode = NSURLErrorUnknown;
if (code < 500) { if (code >= 400) errCode = NSURLErrorResourceUnavailable; }
else if (code < 600) errCode = NSURLErrorBadServerResponse;
return [NSError errorWithDomain:NSURLErrorDomain code:errCode userInfo:info];
}
@end

31
baRSS/Helper/NSURL+Ext.h Normal file
View File

@@ -0,0 +1,31 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
@interface NSURL (Ext)
+ (NSURL*)faviconsCacheURL;
- (BOOL)existsAndIsDir:(BOOL)dir;
- (BOOL)mkdir;
- (void)remove;
- (void)moveTo:(NSURL*)destination;
@end

76
baRSS/Helper/NSURL+Ext.m Normal file
View File

@@ -0,0 +1,76 @@
//
// 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 "NSURL+Ext.h"
#import "UserPrefs.h" // appName in +faviconsCacheURL
@implementation NSURL (Ext)
/// @return Directory URL pointing to "Application Support/baRSS/favicons". Does @b not create directory!
+ (NSURL*)faviconsCacheURL {
static NSURL *path = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
path = [[NSFileManager defaultManager] URLForDirectory:NSApplicationSupportDirectory inDomain:NSUserDomainMask appropriateForURL:nil create:YES error:nil];
path = [path URLByAppendingPathComponent:[UserPrefs appName] isDirectory:YES];
path = [path URLByAppendingPathComponent:@"favicons" isDirectory:YES];
});
return path;
}
/// @return @c YES if and only if item exists at URL and item matches @c dir flag
- (BOOL)existsAndIsDir:(BOOL)dir {
BOOL d;
return self.path && [[NSFileManager defaultManager] fileExistsAtPath:self.path isDirectory:&d] && d == dir;
}
/**
Create directory at URL. If directory exists, this method does nothing.
@return @c YES if dir created successfully. @c NO if dir already exists or an error occured.
*/
- (BOOL)mkdir {
if ([self existsAndIsDir:YES]) return NO;
NSError *err;
BOOL b = [[NSFileManager defaultManager] createDirectoryAtURL:self withIntermediateDirectories:YES attributes:nil error:&err];
if (err) [NSApp presentError:err];
return b;
}
/// Delete file or folder at URL. If item does not exist, this method does nothing.
- (void)remove {
BOOL success = [[NSFileManager defaultManager] removeItemAtURL:self error:nil];
#ifdef DEBUG
if (success) printf("DEL %s\n", self.absoluteString.UTF8String);
#endif
}
/// Move file to destination (by replacing any existing file)
- (void)moveTo:(NSURL*)destination {
[[NSFileManager defaultManager] removeItemAtURL:destination error:nil];
[[NSFileManager defaultManager] moveItemAtURL:self toURL:destination error:nil];
#ifdef DEBUG
printf("MOVE %s\n", self.absoluteString.UTF8String);
printf(" ↳ %s\n", destination.absoluteString.UTF8String);
#endif
}
@end

View File

@@ -0,0 +1,29 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
@interface NSURLRequest (Ext)
+ (instancetype)withURL:(NSString*)urlStr;
- (NSURLSessionDataTask*)dataTask:(nonnull void(^)(NSData * _Nullable data, NSError * _Nullable error, NSHTTPURLResponse *response))block;
- (NSURLSessionDownloadTask*)downloadTask:(void(^)(NSURL * _Nullable path, NSError * _Nullable error))block;
@end

View File

@@ -0,0 +1,97 @@
//
// 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 "NSURLRequest+Ext.h"
#import "NSString+Ext.h"
#import "NSError+Ext.h"
/// @return Shared URL session with caches disabled, enabled gzip encoding and custom user agent.
static NSURLSession* NonCachingURLSession(void) {
static NSURLSession *session = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSURLSessionConfiguration *conf = [NSURLSessionConfiguration defaultSessionConfiguration];
conf.HTTPCookieAcceptPolicy = NSHTTPCookieAcceptPolicyNever;
conf.HTTPShouldSetCookies = NO;
conf.HTTPCookieStorage = nil; // disables '~/Library/Cookies/'
conf.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
conf.URLCache = nil; // disables '~/Library/Caches/de.relikd.baRSS/'
conf.HTTPAdditionalHeaders = @{ @"User-Agent": @"baRSS (macOS)",
@"Accept-Encoding": @"gzip" };
session = [NSURLSession sessionWithConfiguration:conf];
});
return session;
}
@implementation NSURLRequest (Ext)
/// @return New request from URL. Ensures that at least @c http scheme is set.
+ (instancetype)withURL:(NSString*)urlStr {
NSURL *url = [NSURL URLWithString:urlStr];
if (!url.scheme)
url = [NSURL URLWithString:[NSString stringWithFormat:@"http://%@", urlStr]]; // will redirect to https
return [self requestWithURL:url];
}
/// Perform request with non caching @c NSURLSession . If HTTP status code is @c 304 then @c data @c = @c nil.
- (NSURLSessionDataTask*)dataTask:(nonnull void(^)(NSData * _Nullable data, NSError * _Nullable error, NSHTTPURLResponse *response))block {
NSURLSessionDataTask *task = [NonCachingURLSession() dataTaskWithRequest:self completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
NSHTTPURLResponse* httpResponse = (NSHTTPURLResponse*)response;
NSInteger status = [httpResponse statusCode];
#ifdef DEBUG
/*if (status != 304)*/ printf("GET %ld %s\n", status, self.URL.absoluteString.UTF8String);
#endif
if (error || status == 304) {
data = nil; // if status == 304, data & error nil
} else if (status >= 400 && status < 600) { // catch Client & Server errors
error = [NSError statusCode:status reason:(status >= 500 ? [NSString plainTextFromHTMLData:data] : nil)];
data = nil;
}
block(data, error, httpResponse);
}];
[task resume];
return task;
}
/// Prepare a download task and immediatelly perform request with non caching URL session.
- (NSURLSessionDownloadTask*)downloadTask:(void(^)(NSURL * _Nullable path, NSError * _Nullable error))block {
NSURLSessionDownloadTask *task = [NonCachingURLSession() downloadTaskWithRequest:self completionHandler:^(NSURL * _Nullable location, NSURLResponse * _Nullable response, NSError * _Nullable error) {
block(location, error);
}];
[task resume];
return task;
}
/*
Developer Tip, error log:
Task <..> HTTP load failed (error code: -1003 [12:8])
Task <..> finished with error - code: -1003 --- NSURLErrorCannotFindHost
==> NSURLErrorCannotFindHost in #import <Foundation/NSURLError.h>
TIC TCP Conn Failed [21:0x1d417fb00]: 1:65 Err(65) --- EHOSTUNREACH, No route to host
TIC Read Status [9:0x0]: 1:57 --- ENOTCONN, Socket is not connected
==> EHOSTUNREACH in #import <sys/errno.h>
*/
@end

View File

@@ -70,7 +70,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>11519</string>
<string>13197</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.news</string>
<key>LSMinimumSystemVersion</key>

View File

@@ -20,20 +20,25 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import RSXML;
#import "ModalFeedEdit.h"
#import "WebFeed.h"
#import "StoreCoordinator.h"
#import "ModalFeedEditView.h"
#import "RefreshStatisticsView.h"
#import "Constants.h"
#import "FeedDownload.h"
#import "FaviconDownload.h"
#import "Feed+Ext.h"
#import "FeedMeta+Ext.h"
#import "FeedGroup+Ext.h"
#import "ModalFeedEditView.h"
#import "RefreshStatisticsView.h"
#import "NSDate+Ext.h"
#import "NSView+Ext.h"
#import "NSDate+Ext.h"
#import "NSURL+Ext.h"
#pragma mark - ModalEditDialog -
// ################################################################
// #
// # MARK: - ModalEditDialog -
// #
// ################################################################
@interface ModalEditDialog() <NSWindowDelegate>
@property (strong) FeedGroup *feedGroup;
@@ -62,21 +67,20 @@
}
@end
// ################################################################
// #
// # MARK: - ModalFeedEdit -
// #
// ################################################################
#pragma mark - ModalFeedEdit -
@interface ModalFeedEdit() <RefreshIntervalButtonDelegate>
@interface ModalFeedEdit() <FeedDownloadDelegate, RefreshIntervalButtonDelegate, FaviconDownloadDelegate>
@property (strong) IBOutlet ModalFeedEditView *view; // override
@property (strong) RefreshStatisticsView *statisticsView;
@property (copy) NSString *previousURL; // check if changed and avoid multiple download
@property (copy) NSString *faviconURL;
@property (strong) NSError *feedError; // download error or xml parser error
@property (strong) RSParsedFeed *feedResult; // parsed result
@property (strong) NSHTTPURLResponse *httpResponse;
@property (assign) BOOL didDownloadFeed; // check if feed articles need update
@property (strong) NSURL *faviconFile;
@property (strong) FeedDownload *memFeed;
@property (weak) FaviconDownload *memIcon;
@property (strong) RefreshStatisticsView *statisticsView;
@end
@implementation ModalFeedEdit
@@ -91,12 +95,11 @@
[self populateTextFields:self.feedGroup];
}
/**
Pre-fill UI control field values with @c FeedGroup properties.
*/
/// Pre-fill UI control field values with @c FeedGroup properties.
- (void)populateTextFields:(FeedGroup*)fg {
if (!fg || [fg hasChanges]) return; // hasChanges is true only if newly created
self.view.name.objectValue = fg.name;
self.view.name.objectValue = fg.name; // user given feed title
self.view.name.placeholderString = fg.feed.title; // actual feed title
self.view.url.objectValue = fg.feed.meta.url;
self.previousURL = self.view.url.stringValue;
self.view.favicon.image = [fg.feed iconImage16];
@@ -104,6 +107,10 @@
[self statsForCoreDataObject];
}
- (void)dealloc {
[self.faviconFile remove]; // Delete temporary favicon (if still exists)
}
#pragma mark - Edit Feed Data
/**
@@ -111,63 +118,42 @@
Set @c scheduled to a new date if refresh interval was changed.
*/
- (void)applyChangesToCoreDataObject {
Feed *feed = self.feedGroup.feed;
Feed *f = self.feedGroup.feed;
Interval intv = [NSDate intervalForPopup:self.view.refreshUnit andField:self.view.refreshNum];
[self.feedGroup setNameIfChanged:self.view.name.stringValue];
FeedMeta *meta = feed.meta;
[meta setUrlIfChanged:self.previousURL];
[meta setRefreshAndSchedule:[NSDate intervalForPopup:self.view.refreshUnit andField:self.view.refreshNum]];
// updateTimer will be scheduled once preferences is closed
if (self.didDownloadFeed) {
[meta setSucessfulWithResponse:self.httpResponse];
[feed updateWithRSS:self.feedResult postUnreadCountChange:YES];
[feed setIconImage:self.view.favicon.image];
[f.meta setRefreshIfChanged:intv];
if (self.memFeed) {
[self.memFeed copyValuesTo:f ignoreError:YES];
[f setNewIcon:self.faviconFile]; // only if downloaded anything (nil deletes icon!)
self.faviconFile = nil;
}
}
/// Cancel any running download task and free volatile variables
- (void)cancelDownloads {
[self.memFeed cancel]; self.memFeed = nil;
[self.memIcon cancel]; self.memIcon = nil;
[self.faviconFile remove]; self.faviconFile = nil;
}
/**
Prepare UI (nullify @c result, @c error and start @c ProgressIndicator).
Also disable 'Done' button during download and re-enable after all downloads are finished.
Prepare UI (nullify results and start @c ProgressIndicator ).
Also disable 'Done' button during download and re-enable after download is finished.
*/
- (void)preDownload {
- (void)downloadRSS {
[self cancelDownloads];
[self.modalSheet setDoneEnabled:NO]; // prevent user from closing the dialog during download
[self.view.spinnerURL startAnimation:nil];
[self.view.spinnerName startAnimation:nil];
self.view.favicon.image = nil;
self.view.warningButton.hidden = YES;
self.didDownloadFeed = NO;
// Assuming the user has not changed title since the last fetch.
// Reset to "" because after download it will be pre-filled with new feed title
if ([self.view.name.stringValue isEqualToString:self.feedResult.title]) {
// User didn't change title since last fetch. Will be pre-filled with new title after download
if ([self.view.name.stringValue isEqualToString:self.view.name.placeholderString]) {
self.view.name.stringValue = @"";
self.view.name.placeholderString = NSLocalizedString(@"Loading …", nil);
}
self.feedError = nil;
self.feedResult = nil;
self.httpResponse = nil;
self.faviconURL = nil;
self.previousURL = self.view.url.stringValue;
}
/**
All properties will be parsed and stored in class variables.
This should avoid unnecessary core data operations if user decides to cancel the edit.
The save operation will only be executed if user clicks on the 'OK' button.
*/
- (void)downloadRSS {
if (self.modalSheet.didCloseAndCancel)
return;
[self preDownload];
[WebFeed newFeed:self.previousURL askUser:^NSString *(RSHTMLMetadata *meta) {
self.faviconURL = [WebFeed faviconUrlForMetadata:meta]; // we can re-use favicon url if we find one
return [self letUserChooseXmlUrlFromList:meta.feedLinks];
} block:^(RSParsedFeed *result, NSError *error, NSHTTPURLResponse* response) {
if (self.modalSheet.didCloseAndCancel)
return;
self.didDownloadFeed = YES;
self.feedResult = result;
self.feedError = error;
self.httpResponse = response;
[self postDownload:response.URL.absoluteString];
}];
self.memFeed = [[FeedDownload withURL:self.previousURL] startWithDelegate:self];
}
/**
@@ -176,12 +162,7 @@
@return Either URL string or @c nil if user canceled the selection.
*/
- (NSString*)letUserChooseXmlUrlFromList:(NSArray<RSHTMLMetadataFeedLink*> *)list {
if (list.count == 1) { // nothing to choose
// Feeds like https://news.ycombinator.com/ return 503 if URLs are requested too rapidly
//CFRunLoopRunInMode(kCFRunLoopDefaultMode, 1.0, false); // Non-blocking sleep (1s)
return list.firstObject.link;
}
- (NSString*)feedDownload:(FeedDownload*)sender selectFeedFromList:(NSArray<RSHTMLMetadataFeedLink*>*)list {
NSMenu *menu = [[NSMenu alloc] initWithTitle:NSLocalizedString(@"Choose feed menu", nil)];
menu.autoenablesItems = NO;
for (RSHTMLMetadataFeedLink *fl in list) {
@@ -196,62 +177,53 @@
return nil; // user selection canceled
}
/**
Update UI TextFields with downloaded values.
Title will be updated if TextField is empty. URL on redirect.
Finally begin favicon download and return control to user (enable 'Done' button).
*/
- (void)postDownload:(NSString*)responseURL {
if (self.modalSheet.didCloseAndCancel)
return;
BOOL hasError = (self.feedError != nil);
// 1. Stop spinner animation for name field. (keep spinner for URL running until favicon downloaded)
[self.view.spinnerName stopAnimation:nil];
// 2. If URL was redirected, replace original text field value with new one. (e.g., https redirect)
if (responseURL.length > 0 && ![responseURL isEqualToString:self.previousURL]) {
if (!hasError) {
/// If URL was redirected, replace original text field value with new one. (e.g., https redirect)
- (void)feedDownload:(FeedDownload*)sender urlRedirected:(NSString*)newURL {
if (!sender.error) {
// If the url has changed and there is an error:
// This probably means the feed URL was resolved, but the successive download returned 5xx error.
// Presumably to prevent site crawlers accessing many pages in quick succession. (delay of 1s does help)
// By not setting previousURL, a second hit on the 'Done' button will retry the resolved URL again.
self.previousURL = responseURL;
self.previousURL = newURL;
}
self.view.url.stringValue = responseURL;
self.view.url.stringValue = newURL;
}
// 3. Copy parsed feed title to text field. (only if user hasn't set anything else yet)
NSString *parsedTitle = self.feedResult.title;
if (parsedTitle.length > 0 && [self.view.name.stringValue isEqualToString:@""]) {
self.view.name.stringValue = parsedTitle; // no damage to replace an empty string
/// Update UI TextFields with downloaded values. Title updated if TextField is empty, URL if redirect.
- (void)feedDownloadDidFinish:(FeedDownload*)sender {
// Stop spinner for name field but keep running for URL until favicon downloaded
[self.view.spinnerName stopAnimation:nil];
NSString *newTitle = sender.xmlfeed.title;
self.view.name.placeholderString = newTitle;
if (newTitle.length > 0 && self.view.name.stringValue.length == 0) {
self.view.name.stringValue = newTitle; // only if default title wasn't changed
}
// 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)
[self statsForDownloadObject:sender.xmlfeed.articles];
BOOL hasError = (sender.error != nil);
self.view.favicon.hidden = hasError;
self.view.warningButton.hidden = !hasError;
if (hasError) {
[self finishDownloadWithFavicon];
} else {
if (!self.faviconURL)
self.faviconURL = self.feedResult.link;
if (self.faviconURL.length == 0)
self.faviconURL = responseURL;
[WebFeed downloadFavicon:self.faviconURL finished:^(NSImage * _Nullable img) {
if (self.modalSheet.didCloseAndCancel)
return;
self.view.favicon.image = img;
[self finishDownloadWithFavicon];
}];
}
// Start favicon download
if (hasError)
[self downloadComplete];
else
self.memIcon = [[sender faviconDownload] startWithDelegate:self];
}
/**
The last step of the download process.
Stop spinning animation set favivon image preview (right of url bar) and re-enable 'Done' button.
Stop spinning animation, set favivon image (right of url bar), and re-enable 'Done' button.
*/
- (void)finishDownloadWithFavicon {
if (self.modalSheet.didCloseAndCancel)
return;
- (void)faviconDownload:(FaviconDownload*)sender didFinish:(nullable NSURL*)path {
// Create image from favicon temporary file location or default icon if no favicon exists.
NSImage *img = path ? [[NSImage alloc] initByReferencingURL:path] : [NSImage imageNamed:RSSImageDefaultRSSIcon];
self.view.favicon.image = img;
self.faviconFile = path;
[self downloadComplete];
}
/// Called regardless of favicon download.
- (void)downloadComplete {
[self.view.spinnerURL stopAnimation:nil];
[self.modalSheet setDoneEnabled:YES];
}
@@ -259,15 +231,15 @@
#pragma mark - Feed Statistics
/// Perform statistics on newly downloaded feed item
- (void)statsForDownloadObject {
NSMutableArray<NSDate*> *arr = [NSMutableArray arrayWithCapacity:self.feedResult.articles.count];
for (RSParsedArticle *a in self.feedResult.articles) {
- (void)statsForDownloadObject:(NSArray<RSParsedArticle*>*)articles {
NSMutableArray<NSDate*> *arr = [NSMutableArray arrayWithCapacity:articles.count];
for (RSParsedArticle *a in articles) {
NSDate *d = a.datePublished;
if (!d) d = a.dateModified;
if (!d) continue;
[arr addObject:d];
}
[self appendViewWithFeedStatistics:arr count:self.feedResult.articles.count];
[self appendViewWithFeedStatistics:arr count:articles.count];
}
/// Perform statistics on stored core data object
@@ -301,8 +273,10 @@
/// Window delegate will be only called on button 'Done'.
- (BOOL)windowShouldClose:(NSWindow *)sender {
if (![self.previousURL isEqualToString:self.view.url.stringValue]) {
- (BOOL)windowShouldClose:(ModalSheet*)sender {
if (sender.didTapCancel) {
[self cancelDownloads];
} else if (![self.previousURL isEqualToString:self.view.url.stringValue]) { // 'Done' button
[[NSNotificationCenter defaultCenter] postNotificationName:NSControlTextDidEndEditingNotification object:self.view.url];
return NO;
}
@@ -311,7 +285,7 @@
/// Whenever the user finished entering the url (return key or focus change) perform a download request.
- (void)controlTextDidEndEditing:(NSNotification*)obj {
if (obj.object == self.view.url) {
if (obj.object == self.view.url && !self.modalSheet.didTapCancel) {
if (![self.previousURL isEqualToString:self.view.url.stringValue]) {
[self downloadRSS];
}
@@ -320,15 +294,18 @@
/// Warning button next to url text field. Will be visible if an error occurs during download.
- (void)didClickWarningButton:(NSButton*)sender {
if (!self.feedError)
return;
NSError *err = self.memFeed.error;
if (!err) return;
// show reload button if server is temporarily offline (any 5xx server error)
BOOL serverError = (self.feedError.domain == NSURLErrorDomain && self.feedError.code == NSURLErrorBadServerResponse);
BOOL serverError = (err.code == NSURLErrorBadServerResponse && err.domain == NSURLErrorDomain);
self.view.warningReload.hidden = !serverError;
// set error description as text
self.view.warningText.objectValue = self.feedError.localizedDescription;
if (serverError)
self.view.warningText.stringValue = [NSString stringWithFormat:@"%@\n\n%@", err.localizedDescription, err.localizedRecoverySuggestion];
else
self.view.warningText.objectValue = err.localizedDescription;
NSSize newSize = self.view.warningText.fittingSize; // width is limited by the textfield's preferred width
newSize.width += 2 * self.view.warningText.frame.origin.x; // the padding
newSize.height += 2 * self.view.warningText.frame.origin.y;
@@ -345,9 +322,11 @@
@end
#pragma mark - ModalGroupEdit -
// ################################################################
// #
// # MARK: - ModalGroupEdit -
// #
// ################################################################
@implementation ModalGroupEdit
/// Init view and set group name if edeting an already existing object.

View File

@@ -144,9 +144,8 @@ const NSPasteboardType dragReorder = @"de.relikd.baRSS.drag-reorder";
return;
}
// Get list of feeds, and root level selection
NSUInteger count = moc.insertedObjects.count;
NSMutableArray<NSIndexPath*> *selection = [NSMutableArray arrayWithCapacity:count];
NSMutableArray<Feed*> *feedsList = [NSMutableArray arrayWithCapacity:count];
NSMutableArray<NSIndexPath*> *selection = [NSMutableArray array];
NSMutableArray<Feed*> *feedsList = [NSMutableArray array];
for (__kindof NSManagedObject *obj in moc.insertedObjects) {
if ([obj isKindOfClass:[Feed class]]) {
[feedsList addObject:obj]; // list of feeds that need download
@@ -161,8 +160,10 @@ const NSPasteboardType dragReorder = @"de.relikd.baRSS.drag-reorder";
if (selection.count > 0)
[self.dataStore setSelectionIndexPaths:[selection sortedArrayUsingSelector:@selector(compare:)]];
[UpdateScheduler downloadList:feedsList background:NO finally:^{
[UpdateScheduler downloadList:feedsList userInitiated:YES finally:^{
[self endCoreDataChangeUndoEmpty:NO forceUndo:NO];
for (Feed *f in feedsList)
[moc refreshObject:f.group mergeChanges:NO]; // fixes blank icon if imported with no inet conn
[UpdateScheduler scheduleNextFeed];
}];
}
@@ -243,7 +244,7 @@ const NSPasteboardType dragReorder = @"de.relikd.baRSS.drag-reorder";
return result;
}
NS_INLINE BOOL IndexPathIsChildOfParent(NSIndexPath *child, NSIndexPath *parent) {
static inline BOOL IndexPathIsChildOfParent(NSIndexPath *child, NSIndexPath *parent) {
while (child.length > parent.length)
child = [child indexPathByRemovingLastIndex];
return [child isEqualTo:parent];

View File

@@ -47,9 +47,9 @@
- (void)viewDidLoad {
[super viewDidLoad];
// Register for notifications
RegisterNotification(kNotificationFeedUpdated, @selector(feedUpdated:), self);
RegisterNotification(kNotificationArticlesUpdated, @selector(feedUpdated:), self);
RegisterNotification(kNotificationFeedIconUpdated, @selector(feedUpdated:), self);
RegisterNotification(kNotificationGroupInserted, @selector(groupInserted:), self);
RegisterNotification(kNotificationFeedGroupInserted, @selector(feedGroupInserted:), self);
// Status bar
RegisterNotification(kNotificationScheduleTimerChanged, @selector(updateStatusInfo), self);
RegisterNotification(kNotificationNetworkStatusChanged, @selector(updateStatusInfo), self);
@@ -58,6 +58,8 @@
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
NSUInteger c = [StoreCoordinator cleanupFavicons];
if (c > 0) NSLog(@"Removed %lu unreferenced favicons", c);
}
/// Initialize status info timer
@@ -97,10 +99,8 @@
self.dataStore.sortDescriptors = [NSArray arrayWithObject:[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]];
NSError *error;
BOOL ok = [self.dataStore fetchWithRequest:nil merge:NO error:&error];
if (!ok || error) {
[[NSApplication sharedApplication] presentError:error];
}
[self.dataStore fetchWithRequest:nil merge:NO error:&error];
if (error) [NSApp presentError:error];
}
/**
@@ -160,7 +160,7 @@
}
/// Callback method fired when feed is inserted via a 'feed://' url
- (void)groupInserted:(NSNotification*)notify {
- (void)feedGroupInserted:(NSNotification*)notify {
[self.dataStore fetch:self];
}
@@ -312,7 +312,7 @@
}
if ([self endCoreDataChangeUndoEmpty:YES forceUndo:(returnCode != NSModalResponseOK)]) {
if (!flag) [UpdateScheduler scheduleNextFeed]; // only for feed edit
[self.dataStore rearrangeObjects]; // update display, edited title or icon
[self.dataStore.managedObjectContext refreshObject:fg mergeChanges:NO]; // update title & icon
}
}];
}

View File

@@ -203,7 +203,7 @@ NSUserInterfaceItemIdentifier const CustomCellName = @"NameColumnCell";
}
- (void)setObjectValue:(FeedGroup*)fg {
self.textField.objectValue = fg.name;
self.textField.objectValue = fg.anyName;
self.imageView.image = fg.iconImage16;
}

View File

@@ -23,7 +23,7 @@
@import Cocoa;
@interface ModalSheet : NSPanel
@property (readonly) BOOL didCloseAndCancel;
@property (readonly) BOOL didTapCancel;
- (instancetype)initWithContentRect:(NSRect)contentRect styleMask:(NSWindowStyleMask)style backing:(NSBackingStoreType)backingStoreType defer:(BOOL)flag NS_UNAVAILABLE;
- (instancetype)initWithView:(NSView*)content NS_DESIGNATED_INITIALIZER;

View File

@@ -81,14 +81,14 @@
/**
Called after user has clicked the 'Done' (Return) or 'Cancel' (Esc) button.
In the later case set @c .didCloseAndCancel @c = @c YES
In the later case set @c .didTapCancel @c = @c YES
*/
- (void)didTapButton:(NSButton*)sender {
BOOL successful = (sender.tag == 42); // 'Done' button
if (successful && self.respondToShouldClose && ![self.delegate windowShouldClose:self]) {
_didTapCancel = !successful;
if (self.respondToShouldClose && ![self.delegate windowShouldClose:self]) {
return;
}
_didCloseAndCancel = !successful;
// Save modal view width for next time
CGFloat w = NSWidth(self.contentView.frame) - 2 * PAD_WIN;
[[NSUserDefaults standardUserDefaults] setInteger:(NSInteger)w forKey:@"modalSheetWidth"];

View File

@@ -28,6 +28,7 @@
#import "MapUnreadTotal.h"
#import "StoreCoordinator.h"
#import "Feed+Ext.h"
#import "FeedGroup+Ext.h"
#import "FeedArticle+Ext.h"
@@ -45,7 +46,7 @@
// TODO: move unread counts to status item and keep in sync when changing feeds in preferences
self.unreadMap = [[MapUnreadTotal alloc] initWithCoreData: [StoreCoordinator countAggregatedUnread]];
// Register for notifications
RegisterNotification(kNotificationFeedUpdated, @selector(feedUpdated:), self);
RegisterNotification(kNotificationArticlesUpdated, @selector(articlesUpdated:), self);
RegisterNotification(kNotificationFeedIconUpdated, @selector(feedIconUpdated:), self);
return self;
}
@@ -129,21 +130,15 @@
- (void)updateFeedMenuItem:(NSManagedObjectID*)oid withBlock:(void(^)(Feed *feed, NSMenuItem *item))block {
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
Feed *feed = [moc objectWithID:oid];
if (![feed isKindOfClass:[Feed class]]) {
[moc reset];
return;
}
if ([feed isKindOfClass:[Feed class]]) {
NSMenuItem *item = [self.statusItem.mainMenu deepestItemWithPath:feed.indexPath];
if (!item) {
[moc reset];
return;
if (item) block(feed, item);
}
block(feed, item);
[moc reset];
}
/// Callback method fired when feed has been updated in the background.
- (void)feedUpdated:(NSNotification*)notify {
- (void)articlesUpdated:(NSNotification*)notify {
[self updateFeedMenuItem:notify.object withBlock:^(Feed *feed, NSMenuItem *item) {
// 1. update in-memory unread count
UnreadTotal *updated = [UnreadTotal new];
@@ -154,8 +149,7 @@
[self.unreadMap updateAllCounts:updated forPath:feed.indexPath];
// 2. rebuild articles menu if it is open
if (item.submenu.isFeedMenu) { // menu item is visible
if (feed.group.name)
item.title = feed.group.name; // will replace (no title)
item.title = feed.group.anyName; // will replace (no title)
item.image = [feed iconImage16];
item.enabled = (feed.articles.count > 0);
if (item.submenu.numberOfItems > 0) { // replace articles menu