Refactoring feed download + favicon cache
This commit is contained in:
@@ -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)
|
- Associate OPML files (double click and right click actions in Finder)
|
||||||
- Quick Look preview for OPML files
|
- 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:* 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
|
- *Adding feed:* `⌘R` will reload the same URL
|
||||||
- *Settings, Feeds:* `⌘R` will reload the data source
|
- *Settings, Feeds:* `⌘R` will reload the data source
|
||||||
- *Settings, Feeds:* Refresh interval string localizations
|
- *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`
|
- Config URL scheme `barss:` with `open/preferences` and `config/fixcache`
|
||||||
|
|
||||||
### Fixed
|
### 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:* 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:* Prefer favicons with size `32x32`
|
||||||
- *Adding feed:* Inserting feeds when offline will postpone download until network is reachable again
|
- *Adding feed:* Inserting feeds when offline/paused will postpone download until network is reachable again
|
||||||
- *Adding feed:* Inserting feeds when paused will postpone download until unpaused
|
- *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:* 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 with accurate download count (instead of `Updating feeds …`)
|
||||||
- *Settings, Feeds:* Status info shows `No network connection` and `Updates paused`
|
- *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:* Interface builder files replaced with code equivalent
|
||||||
- *UI:* Mark unread articles with blue dot, instead of tick mark
|
- *UI:* Mark unread articles with blue dot, instead of tick mark
|
||||||
- *DB*: New table for options. E.g., what app version modified the database
|
- *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
|
## [0.9.4] - 2019-04-02
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
544DCCB9212A2B4D002DBC46 /* RSXML.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 544DCCB8212A2B4D002DBC46 /* RSXML.framework */; };
|
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, ); }; };
|
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 */; };
|
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 */; };
|
546A6A2922C583390034E806 /* SettingsGeneralView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D857D122802309001BA1C8 /* SettingsGeneralView.m */; };
|
||||||
546A6A2C22C584AF0034E806 /* SettingsAppearanceView.m in Sources */ = {isa = PBXBuildFile; fileRef = 546A6A2A22C584AF0034E806 /* SettingsAppearanceView.m */; };
|
546A6A2C22C584AF0034E806 /* SettingsAppearanceView.m in Sources */ = {isa = PBXBuildFile; fileRef = 546A6A2A22C584AF0034E806 /* SettingsAppearanceView.m */; };
|
||||||
546A6A2F22C585580034E806 /* SettingsAboutView.m in Sources */ = {isa = PBXBuildFile; fileRef = 546A6A2D22C585580034E806 /* SettingsAboutView.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 */; };
|
546FC44321189975007CC3A3 /* SettingsGeneral.m in Sources */ = {isa = PBXBuildFile; fileRef = 546FC44121189975007CC3A3 /* SettingsGeneral.m */; };
|
||||||
5477D34E21233C62002BA27F /* FeedGroup+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 5477D34D21233C62002BA27F /* FeedGroup+Ext.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 */; };
|
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 */; };
|
5496B511214D6275003ED4ED /* UserPrefs.m in Sources */ = {isa = PBXBuildFile; fileRef = 5496B510214D6275003ED4ED /* UserPrefs.m */; };
|
||||||
54A07A7F220E04CF00082C51 /* NSFetchRequest+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54A07A7E220E04CF00082C51 /* NSFetchRequest+Ext.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 */; };
|
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 */; };
|
54ACC28C21061B3C0020715F /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC28B21061B3C0020715F /* main.m */; };
|
||||||
54ACC29521061E270020715F /* UpdateScheduler.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC29421061E270020715F /* UpdateScheduler.m */; };
|
54ACC29521061E270020715F /* UpdateScheduler.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC29421061E270020715F /* UpdateScheduler.m */; };
|
||||||
54ACC29821061FBA0020715F /* Preferences.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC29721061FBA0020715F /* Preferences.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 */; };
|
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 */; };
|
54AD4EE72305B17D000AE386 /* container-migration.plist in Resources */ = {isa = PBXBuildFile; fileRef = 54AD4EE62305B17D000AE386 /* container-migration.plist */; };
|
||||||
54B51704226DC339006C1B29 /* ModalFeedEditView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B51703226DC339006C1B29 /* ModalFeedEditView.m */; };
|
54B51704226DC339006C1B29 /* ModalFeedEditView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B51703226DC339006C1B29 /* ModalFeedEditView.m */; };
|
||||||
54B517072270E990006C1B29 /* NSView+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B517062270E92A006C1B29 /* NSView+Ext.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 */; };
|
54B749DA2204A85C0022CC6D /* BarStatusItem.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B749D92204A85C0022CC6D /* BarStatusItem.m */; };
|
||||||
54B749E0220636200022CC6D /* FeedArticle+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B749DF220635CD0022CC6D /* FeedArticle+Ext.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 */; };
|
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 */; };
|
54D55D7322E624CD00057B98 /* SettingsFeeds+DragDrop.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D55D7222E624CD00057B98 /* SettingsFeeds+DragDrop.m */; };
|
||||||
54D857CE227C5785001BA1C8 /* RefreshStatisticsView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D857CD227C5785001BA1C8 /* RefreshStatisticsView.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 */; };
|
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 */; };
|
54E88320211B509D00064188 /* ModalFeedEdit.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E8831E211B509D00064188 /* ModalFeedEdit.m */; };
|
||||||
54E9CF32225914300023696F /* SettingsAbout.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E9CF31225914300023696F /* SettingsAbout.m */; };
|
54E9CF32225914300023696F /* SettingsAbout.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E9CF31225914300023696F /* SettingsAbout.m */; };
|
||||||
54F39C2E210BE1F700AEE730 /* DBv1.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC28221061B3B0020715F /* DBv1.xcdatamodeld */; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
54E9CF30225914300023696F /* SettingsAbout.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SettingsAbout.h; sourceTree = "<group>"; };
|
||||||
@@ -210,12 +222,18 @@
|
|||||||
children = (
|
children = (
|
||||||
54209E922117325100F3B5EF /* DrawImage.h */,
|
54209E922117325100F3B5EF /* DrawImage.h */,
|
||||||
54209E932117325100F3B5EF /* DrawImage.m */,
|
54209E932117325100F3B5EF /* DrawImage.m */,
|
||||||
54BB048721FD2AB500C303A5 /* NSDate+Ext.h */,
|
|
||||||
54BB048821FD2AB500C303A5 /* NSDate+Ext.m */,
|
|
||||||
54B517052270E8C6006C1B29 /* NSView+Ext.h */,
|
54B517052270E8C6006C1B29 /* NSView+Ext.h */,
|
||||||
54B517062270E92A006C1B29 /* NSView+Ext.m */,
|
54B517062270E92A006C1B29 /* NSView+Ext.m */,
|
||||||
54AD4E0A2301853D000AE386 /* NSString+Ext.h */,
|
54AD4E0A2301853D000AE386 /* NSString+Ext.h */,
|
||||||
54AD4E0B2301853D000AE386 /* NSString+Ext.m */,
|
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;
|
path = Helper;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -316,8 +334,10 @@
|
|||||||
children = (
|
children = (
|
||||||
54ACC29321061E270020715F /* UpdateScheduler.h */,
|
54ACC29321061E270020715F /* UpdateScheduler.h */,
|
||||||
54ACC29421061E270020715F /* UpdateScheduler.m */,
|
54ACC29421061E270020715F /* UpdateScheduler.m */,
|
||||||
54AD4DFE23005297000AE386 /* WebFeed.h */,
|
5450100E230E9C8600F0B165 /* FeedDownload.h */,
|
||||||
54AD4DFF23005297000AE386 /* WebFeed.m */,
|
5450100F230E9C8600F0B165 /* FeedDownload.m */,
|
||||||
|
54B6F148231551B3002C94C9 /* FaviconDownload.h */,
|
||||||
|
54B6F149231551B3002C94C9 /* FaviconDownload.m */,
|
||||||
54F6025B21C1D4170006D338 /* OpmlFile.h */,
|
54F6025B21C1D4170006D338 /* OpmlFile.h */,
|
||||||
54F6025C21C1D4170006D338 /* OpmlFile.m */,
|
54F6025C21C1D4170006D338 /* OpmlFile.m */,
|
||||||
);
|
);
|
||||||
@@ -537,7 +557,6 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
54AD4E0023005297000AE386 /* WebFeed.m in Sources */,
|
|
||||||
54B51704226DC339006C1B29 /* ModalFeedEditView.m in Sources */,
|
54B51704226DC339006C1B29 /* ModalFeedEditView.m in Sources */,
|
||||||
54AD4E0C2301853D000AE386 /* NSString+Ext.m in Sources */,
|
54AD4E0C2301853D000AE386 /* NSString+Ext.m in Sources */,
|
||||||
546A6A2C22C584AF0034E806 /* SettingsAppearanceView.m in Sources */,
|
546A6A2C22C584AF0034E806 /* SettingsAppearanceView.m in Sources */,
|
||||||
@@ -551,9 +570,12 @@
|
|||||||
54ACC29521061E270020715F /* UpdateScheduler.m in Sources */,
|
54ACC29521061E270020715F /* UpdateScheduler.m in Sources */,
|
||||||
54BB048921FD2AB500C303A5 /* NSDate+Ext.m in Sources */,
|
54BB048921FD2AB500C303A5 /* NSDate+Ext.m in Sources */,
|
||||||
5477D34E21233C62002BA27F /* FeedGroup+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 */,
|
5478DF04225A7AE200D30C64 /* SettingsFeedsView.m in Sources */,
|
||||||
540F704521B6C16C0022E69D /* FeedMeta+Ext.m in Sources */,
|
540F704521B6C16C0022E69D /* FeedMeta+Ext.m in Sources */,
|
||||||
54ACC28C21061B3C0020715F /* main.m in Sources */,
|
54ACC28C21061B3C0020715F /* main.m in Sources */,
|
||||||
|
54B6F14E23155E1A002C94C9 /* NSURLRequest+Ext.m in Sources */,
|
||||||
54FE73D3212316CD003EAC65 /* BarMenu.m in Sources */,
|
54FE73D3212316CD003EAC65 /* BarMenu.m in Sources */,
|
||||||
544B011A2114B41200386E5C /* ModalSheet.m in Sources */,
|
544B011A2114B41200386E5C /* ModalSheet.m in Sources */,
|
||||||
54A07A82220E723D00082C51 /* MapUnreadTotal.m in Sources */,
|
54A07A82220E723D00082C51 /* MapUnreadTotal.m in Sources */,
|
||||||
@@ -567,8 +589,10 @@
|
|||||||
541C67C32255470B004D2CE6 /* SettingsAppearance.m in Sources */,
|
541C67C32255470B004D2CE6 /* SettingsAppearance.m in Sources */,
|
||||||
54B749DA2204A85C0022CC6D /* BarStatusItem.m in Sources */,
|
54B749DA2204A85C0022CC6D /* BarStatusItem.m in Sources */,
|
||||||
546FC43D21188AD5007CC3A3 /* SettingsFeeds.m in Sources */,
|
546FC43D21188AD5007CC3A3 /* SettingsFeeds.m in Sources */,
|
||||||
|
548C6D0A230C33DE003A1AAF /* NSURL+Ext.m in Sources */,
|
||||||
54E88320211B509D00064188 /* ModalFeedEdit.m in Sources */,
|
54E88320211B509D00064188 /* ModalFeedEdit.m in Sources */,
|
||||||
54195883218A061100581B79 /* Feed+Ext.m in Sources */,
|
54195883218A061100581B79 /* Feed+Ext.m in Sources */,
|
||||||
|
54501010230E9C8600F0B165 /* FeedDownload.m in Sources */,
|
||||||
54209E942117325100F3B5EF /* DrawImage.m in Sources */,
|
54209E942117325100F3B5EF /* DrawImage.m in Sources */,
|
||||||
54FE73D021220DEC003EAC65 /* StoreCoordinator.m in Sources */,
|
54FE73D021220DEC003EAC65 /* StoreCoordinator.m in Sources */,
|
||||||
54A07A7F220E04CF00082C51 /* NSFetchRequest+Ext.m in Sources */,
|
54A07A7F220E04CF00082C51 /* NSFetchRequest+Ext.m in Sources */,
|
||||||
|
|||||||
@@ -22,14 +22,14 @@
|
|||||||
|
|
||||||
#import "AppHook.h"
|
#import "AppHook.h"
|
||||||
#import "Constants.h"
|
#import "Constants.h"
|
||||||
#import "BarStatusItem.h"
|
|
||||||
#import "WebFeed.h"
|
|
||||||
#import "UpdateScheduler.h"
|
|
||||||
#import "Preferences.h"
|
|
||||||
#import "DrawImage.h"
|
#import "DrawImage.h"
|
||||||
#import "SettingsFeeds+DragDrop.h"
|
|
||||||
#import "UserPrefs.h"
|
#import "UserPrefs.h"
|
||||||
|
#import "Preferences.h"
|
||||||
|
#import "BarStatusItem.h"
|
||||||
|
#import "UpdateScheduler.h"
|
||||||
#import "StoreCoordinator.h"
|
#import "StoreCoordinator.h"
|
||||||
|
#import "SettingsFeeds+DragDrop.h"
|
||||||
|
#import "NSURL+Ext.h"
|
||||||
|
|
||||||
@interface AppHook()
|
@interface AppHook()
|
||||||
@property (strong) NSWindowController *prefWindow;
|
@property (strong) NSWindowController *prefWindow;
|
||||||
@@ -53,11 +53,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
|
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
|
||||||
|
BOOL initial = [[NSURL faviconsCacheURL] mkdir];
|
||||||
[_statusItem asyncReloadUnreadCount];
|
[_statusItem asyncReloadUnreadCount];
|
||||||
[UpdateScheduler registerNetworkChangeNotification]; // will call update scheduler
|
[UpdateScheduler registerNetworkChangeNotification]; // will call update scheduler
|
||||||
if ([StoreCoordinator isEmpty]) {
|
if ([StoreCoordinator isEmpty]) {
|
||||||
[_statusItem showWelcomeMessage];
|
[_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;
|
@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 {
|
- (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) {
|
@synchronized (self) {
|
||||||
if (_persistentContainer == nil) {
|
if (_persistentContainer == nil) {
|
||||||
_persistentContainer = [[NSPersistentContainer alloc] initWithName:@"DBv1"];
|
_persistentContainer = [[NSPersistentContainer alloc] initWithName:@"DBv1"];
|
||||||
@@ -125,28 +129,23 @@
|
|||||||
return _persistentContainer;
|
return _persistentContainer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Save changes in the application's managed object context before the application terminates.
|
||||||
- (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sender {
|
- (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sender {
|
||||||
// Save changes in the application's managed object context before the application terminates.
|
|
||||||
NSManagedObjectContext *context = self.persistentContainer.viewContext;
|
NSManagedObjectContext *context = self.persistentContainer.viewContext;
|
||||||
|
|
||||||
if (![context commitEditing]) {
|
if (![context commitEditing]) {
|
||||||
NSLog(@"%@:%@ unable to commit editing to terminate", [self class], NSStringFromSelector(_cmd));
|
NSLog(@"%@:%@ unable to commit editing to terminate", [self class], NSStringFromSelector(_cmd));
|
||||||
return NSTerminateCancel;
|
return NSTerminateCancel;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!context.hasChanges) {
|
if (!context.hasChanges) {
|
||||||
return NSTerminateNow;
|
return NSTerminateNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
NSError *error = nil;
|
NSError *error = nil;
|
||||||
if (![context save:&error]) {
|
if (![context save:&error]) {
|
||||||
|
|
||||||
// Customize this code block to include application-specific recovery steps.
|
// Customize this code block to include application-specific recovery steps.
|
||||||
BOOL result = [sender presentError:error];
|
BOOL result = [sender presentError:error];
|
||||||
if (result) {
|
if (result) {
|
||||||
return NSTerminateCancel;
|
return NSTerminateCancel;
|
||||||
}
|
}
|
||||||
|
|
||||||
NSString *question = NSLocalizedString(@"Could not save changes while quitting. Quit anyway?", @"Quit without saves error question message");
|
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 *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");
|
NSString *quitButton = NSLocalizedString(@"Quit anyway", @"Quit anyway button title");
|
||||||
@@ -157,9 +156,7 @@
|
|||||||
[alert addButtonWithTitle:quitButton];
|
[alert addButtonWithTitle:quitButton];
|
||||||
[alert addButtonWithTitle:cancelButton];
|
[alert addButtonWithTitle:cancelButton];
|
||||||
|
|
||||||
NSInteger answer = [alert runModal];
|
if ([alert runModal] == NSAlertSecondButtonReturn) {
|
||||||
|
|
||||||
if (answer == NSAlertSecondButtonReturn) {
|
|
||||||
return NSTerminateCancel;
|
return NSTerminateCancel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,9 +167,7 @@
|
|||||||
#pragma mark - Application Input (URLs and Files)
|
#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 {
|
- (void)application:(NSApplication *)sender openFiles:(NSArray<NSString *> *)filenames {
|
||||||
NSMutableArray<NSURL*> *urls = [NSMutableArray arrayWithCapacity:filenames.count];
|
NSMutableArray<NSURL*> *urls = [NSMutableArray arrayWithCapacity:filenames.count];
|
||||||
for (NSString *file in filenames) {
|
for (NSString *file in filenames) {
|
||||||
@@ -184,9 +179,7 @@
|
|||||||
[sender replyToOpenOrPrint:NSApplicationDelegateReplySuccess];
|
[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 {
|
- (void)handleAppleEvent:(NSAppleEventDescriptor *)event withReplyEvent:(NSAppleEventDescriptor *)replyEvent {
|
||||||
NSString *url = [[event paramDescriptorForKeyword:keyDirectObject] stringValue];
|
NSString *url = [[event paramDescriptorForKeyword:keyDirectObject] stringValue];
|
||||||
NSString *scheme = [[[NSURL URLWithString:url] scheme] lowercaseString];
|
NSString *scheme = [[[NSURL URLWithString:url] scheme] lowercaseString];
|
||||||
@@ -195,7 +188,7 @@
|
|||||||
url = [url substringFromIndex:2];
|
url = [url substringFromIndex:2];
|
||||||
}
|
}
|
||||||
if ([scheme isEqualToString:kURLSchemeFeed]) {
|
if ([scheme isEqualToString:kURLSchemeFeed]) {
|
||||||
[WebFeed autoDownloadAndParseURL:url addAnyway:NO modify:nil];
|
[UpdateScheduler autoDownloadAndParseURL:url];
|
||||||
} else if ([scheme isEqualToString:kURLSchemeBarss]) {
|
} else if ([scheme isEqualToString:kURLSchemeBarss]) {
|
||||||
NSMutableArray<NSString*> *comp = [[url pathComponents] mutableCopy];
|
NSMutableArray<NSString*> *comp = [[url pathComponents] mutableCopy];
|
||||||
NSString *action = comp.firstObject;
|
NSString *action = comp.firstObject;
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ static NSImageName const RSSImageMenuItemUnread = @"RSSImageMenuItemUnread";
|
|||||||
|
|
||||||
|
|
||||||
/// Helper method calls @c (defaultCenter)postNotification:
|
/// Helper method calls @c (defaultCenter)postNotification:
|
||||||
NS_INLINE void PostNotification(NSNotificationName name, id obj) { [[NSNotificationCenter defaultCenter] postNotificationName:name object:obj]; }
|
static 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 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.
|
@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.
|
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.
|
@c notification.object is @c NSManagedObjectID of type @c FeedGroup.
|
||||||
Called whenever a new feed group was created in @c autoDownloadAndParseURL:
|
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.
|
@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.
|
@c notification.object is @c NSManagedObjectID of type @c Feed.
|
||||||
Called whenever the icon attribute of an item was updated.
|
Called whenever the icon attribute of an item was updated.
|
||||||
|
|||||||
@@ -25,16 +25,16 @@
|
|||||||
@class RSParsedFeed;
|
@class RSParsedFeed;
|
||||||
|
|
||||||
@interface Feed (Ext)
|
@interface Feed (Ext)
|
||||||
|
@property (readonly) BOOL hasIcon;
|
||||||
@property (nonnull, readonly) NSImage* iconImage16;
|
@property (nonnull, readonly) NSImage* iconImage16;
|
||||||
|
|
||||||
// Generator methods / Feed update
|
// Generator methods / Feed update
|
||||||
+ (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context;
|
+ (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context;
|
||||||
+ (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc;
|
|
||||||
- (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag;
|
- (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag;
|
||||||
- (void)calculateAndSetIndexPathString;
|
|
||||||
- (NSMenuItem*)newMenuItem;
|
- (NSMenuItem*)newMenuItem;
|
||||||
|
// Getter & Setter
|
||||||
|
- (void)calculateAndSetIndexPathString;
|
||||||
|
- (void)setNewIcon:(NSURL*)location;
|
||||||
// Article properties
|
// Article properties
|
||||||
- (NSArray<FeedArticle*>*)sortedArticles;
|
- (NSArray<FeedArticle*>*)sortedArticles;
|
||||||
// Icon
|
|
||||||
- (BOOL)setIconImage:(NSImage*)img;
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
#import "FeedGroup+Ext.h"
|
#import "FeedGroup+Ext.h"
|
||||||
#import "FeedArticle+Ext.h"
|
#import "FeedArticle+Ext.h"
|
||||||
#import "StoreCoordinator.h"
|
#import "StoreCoordinator.h"
|
||||||
|
#import "NSURL+Ext.h"
|
||||||
|
|
||||||
@implementation Feed (Ext)
|
@implementation Feed (Ext)
|
||||||
|
|
||||||
@@ -38,14 +39,6 @@
|
|||||||
return feed;
|
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.
|
/// Call @c indexPathString on @c .group and update @c .indexPath if current value is different.
|
||||||
- (void)calculateAndSetIndexPathString {
|
- (void)calculateAndSetIndexPathString {
|
||||||
NSString *pthStr = [self.group indexPathString];
|
NSString *pthStr = [self.group indexPathString];
|
||||||
@@ -56,7 +49,7 @@
|
|||||||
/// @return Fully initialized @c NSMenuItem with @c title, @c tooltip, @c image, and @c action.
|
/// @return Fully initialized @c NSMenuItem with @c title, @c tooltip, @c image, and @c action.
|
||||||
- (NSMenuItem*)newMenuItem {
|
- (NSMenuItem*)newMenuItem {
|
||||||
NSMenuItem *item = [NSMenuItem new];
|
NSMenuItem *item = [NSMenuItem new];
|
||||||
item.title = self.group.nameOrError;
|
item.title = self.group.anyName;
|
||||||
item.toolTip = self.subtitle;
|
item.toolTip = self.subtitle;
|
||||||
item.enabled = (self.articles.count > 0);
|
item.enabled = (self.articles.count > 0);
|
||||||
item.image = self.iconImage16;
|
item.image = self.iconImage16;
|
||||||
@@ -85,9 +78,6 @@
|
|||||||
if (![self.subtitle isEqualToString:obj.subtitle]) self.subtitle = obj.subtitle;
|
if (![self.subtitle isEqualToString:obj.subtitle]) self.subtitle = obj.subtitle;
|
||||||
if (![self.link isEqualToString:obj.link]) self.link = obj.link;
|
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
|
// Add and remove articles
|
||||||
NSMutableSet<FeedArticle*> *localSet = [self.articles mutableCopy];
|
NSMutableSet<FeedArticle*> *localSet = [self.articles mutableCopy];
|
||||||
NSInteger diff = 0;
|
NSInteger diff = 0;
|
||||||
@@ -117,6 +107,7 @@
|
|||||||
// reverse enumeration ensures correct article order
|
// reverse enumeration ensures correct article order
|
||||||
FeedArticle *storedArticle = [self findRemoteArticle:article inLocalSet:localSet];
|
FeedArticle *storedArticle = [self findRemoteArticle:article inLocalSet:localSet];
|
||||||
if (storedArticle) {
|
if (storedArticle) {
|
||||||
|
// TODO: stop bullshitting with ghost articles
|
||||||
[localSet removeObject:storedArticle];
|
[localSet removeObject:storedArticle];
|
||||||
// If we encounter an already existing item, assume newly inserted are "ghost" items and mark read.
|
// If we encounter an already existing item, assume newly inserted are "ghost" items and mark read.
|
||||||
if (newlyInserted.count > 0) {
|
if (newlyInserted.count > 0) {
|
||||||
@@ -215,15 +206,13 @@
|
|||||||
#pragma mark - Icon -
|
#pragma mark - Icon -
|
||||||
|
|
||||||
|
|
||||||
/**
|
/// @return @c 16x16px image. Either from favicon cache or generated default RSS icon.
|
||||||
@return Return @c 16x16px image. Either from core data storage or generated default RSS icon.
|
|
||||||
*/
|
|
||||||
- (nonnull NSImage*)iconImage16 {
|
- (nonnull NSImage*)iconImage16 {
|
||||||
NSImage *img = nil;
|
NSImage *img = nil;
|
||||||
if (self.articles.count == 0) {
|
if (self.articles.count == 0) {
|
||||||
img = [NSImage imageNamed:NSImageNameCaution];
|
img = [NSImage imageNamed:NSImageNameCaution];
|
||||||
} else if (self.icon.icon) {
|
} else if (self.hasIcon) {
|
||||||
img = [[NSImage alloc] initWithData:self.icon.icon];
|
img = [[NSImage alloc] initByReferencingURL:[self iconPath]];
|
||||||
} else {
|
} else {
|
||||||
img = [NSImage imageNamed:RSSImageDefaultRSSIcon];
|
img = [NSImage imageNamed:RSSImageDefaultRSSIcon];
|
||||||
}
|
}
|
||||||
@@ -231,23 +220,26 @@
|
|||||||
return img;
|
return img;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/// Checks if file at @c iconPath is an actual file
|
||||||
Set favicon icon or delete relationship if @c img is not a valid image.
|
- (BOOL)hasIcon { return [[self iconPath] existsAndIsDir:NO]; }
|
||||||
|
|
||||||
@return @c YES if icon was updated (core data did change).
|
/// Image file path at e.g., "Application Support/baRSS/favicons/p42". @warning File may not exist!
|
||||||
*/
|
- (NSURL*)iconPath {
|
||||||
- (BOOL)setIconImage:(NSImage*)img {
|
NSString *pk = self.objectID.URIRepresentation.lastPathComponent;
|
||||||
if (img && [img isValid]) {
|
return [[NSURL faviconsCacheURL] URLByAppendingPathComponent:pk isDirectory:NO];
|
||||||
if (!self.icon)
|
}
|
||||||
self.icon = [[FeedIcon alloc] initWithEntity:FeedIcon.entity insertIntoManagedObjectContext:self.managedObjectContext];
|
|
||||||
self.icon.icon = [img TIFFRepresentation];
|
/// Move favicon from @c $TMPDIR to permanent destination in Application Support.
|
||||||
return YES;
|
- (void)setNewIcon:(NSURL*)location {
|
||||||
} else if (self.icon) {
|
if (!location) {
|
||||||
[self.managedObjectContext deleteObject:self.icon];
|
[[self iconPath] remove];
|
||||||
self.icon = nil;
|
} else {
|
||||||
return YES;
|
if (self.objectID.isTemporaryID) {
|
||||||
|
[self.managedObjectContext obtainPermanentIDsForObjects:@[self] error:nil];
|
||||||
|
}
|
||||||
|
[location moveTo:[self iconPath]];
|
||||||
|
PostNotification(kNotificationFeedIconUpdated, self.objectID);
|
||||||
}
|
}
|
||||||
return NO;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -33,14 +33,15 @@ typedef NS_ENUM(int16_t, FeedGroupType) {
|
|||||||
@interface FeedGroup (Ext)
|
@interface FeedGroup (Ext)
|
||||||
/// Overwrites @c type attribute with enum. Use one of: @c GROUP, @c FEED, @c SEPARATOR.
|
/// Overwrites @c type attribute with enum. Use one of: @c GROUP, @c FEED, @c SEPARATOR.
|
||||||
@property (nonatomic) FeedGroupType type;
|
@property (nonatomic) FeedGroupType type;
|
||||||
@property (nonnull, readonly) NSString *nameOrError;
|
@property (nonnull, readonly) NSString *anyName;
|
||||||
@property (nonnull, readonly) NSImage* groupIconImage16;
|
@property (nonnull, readonly) NSImage* groupIconImage16;
|
||||||
@property (nonnull, readonly) NSImage* iconImage16;
|
@property (nonnull, readonly) NSImage* iconImage16;
|
||||||
|
|
||||||
+ (instancetype)newGroup:(FeedGroupType)type inContext:(NSManagedObjectContext*)context;
|
+ (instancetype)newGroup:(FeedGroupType)type inContext:(NSManagedObjectContext*)context;
|
||||||
|
+ (instancetype)appendToRoot:(FeedGroupType)type inContext:(NSManagedObjectContext*)moc;
|
||||||
- (void)setParent:(FeedGroup *)parent andSortIndex:(int32_t)sortIndex;
|
- (void)setParent:(FeedGroup *)parent andSortIndex:(int32_t)sortIndex;
|
||||||
- (void)setSortIndexIfChanged:(int32_t)sortIndex;
|
- (void)setSortIndexIfChanged:(int32_t)sortIndex;
|
||||||
- (void)setNameIfChanged:(NSString*)name;
|
- (void)setNameIfChanged:(nullable NSString*)name;
|
||||||
- (NSMenuItem*)newMenuItem;
|
- (NSMenuItem*)newMenuItem;
|
||||||
// Handle children and parents
|
// Handle children and parents
|
||||||
- (NSString*)indexPathString;
|
- (NSString*)indexPathString;
|
||||||
|
|||||||
@@ -21,17 +21,22 @@
|
|||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
#import "FeedGroup+Ext.h"
|
#import "FeedGroup+Ext.h"
|
||||||
#import "FeedMeta+Ext.h"
|
|
||||||
#import "Feed+Ext.h"
|
#import "Feed+Ext.h"
|
||||||
|
#import "FeedMeta+Ext.h"
|
||||||
|
#import "StoreCoordinator.h"
|
||||||
#import "NSDate+Ext.h"
|
#import "NSDate+Ext.h"
|
||||||
|
|
||||||
@implementation FeedGroup (Ext)
|
@implementation FeedGroup (Ext)
|
||||||
|
|
||||||
#pragma mark - Properties
|
#pragma mark - Properties
|
||||||
|
|
||||||
/// @return Returns "(no title)" if @c self.name is @c nil.
|
/// Try return @c self.name or @c self.feed.title ; If both fail return "(no title)"
|
||||||
- (nonnull NSString*)nameOrError {
|
- (nonnull NSString*)anyName {
|
||||||
return (self.name ? self.name : NSLocalizedString(@"(no title)", nil));
|
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.
|
/// @return Return @c 16x16px NSImageNameFolder image.
|
||||||
@@ -66,6 +71,14 @@
|
|||||||
return fg;
|
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.
|
/// 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 {
|
- (void)setParent:(FeedGroup *)parent andSortIndex:(int32_t)sortIndex {
|
||||||
self.parent = parent;
|
self.parent = parent;
|
||||||
@@ -85,15 +98,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set @c name attribute but only if value differs.
|
/// Set @c name attribute but only if value differs.
|
||||||
- (void)setNameIfChanged:(NSString*)name {
|
- (void)setNameIfChanged:(nullable NSString*)name {
|
||||||
if (![self.name isEqualToString: 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;
|
self.name = name;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// @return Fully initialized @c NSMenuItem with @c title and @c image.
|
/// @return Fully initialized @c NSMenuItem with @c title and @c image.
|
||||||
- (NSMenuItem*)newMenuItem {
|
- (NSMenuItem*)newMenuItem {
|
||||||
NSMenuItem *item = [NSMenuItem new];
|
NSMenuItem *item = [NSMenuItem new];
|
||||||
item.title = self.nameOrError;
|
item.title = self.anyName;
|
||||||
item.enabled = (self.children.count > 0);
|
item.enabled = (self.children.count > 0);
|
||||||
item.image = self.groupIconImage16;
|
item.image = self.groupIconImage16;
|
||||||
item.representedObject = self.objectID;
|
item.representedObject = self.objectID;
|
||||||
@@ -155,8 +172,8 @@
|
|||||||
/// @return Simplified description of the feed object.
|
/// @return Simplified description of the feed object.
|
||||||
- (NSString*)readableDescription {
|
- (NSString*)readableDescription {
|
||||||
switch (self.type) {
|
switch (self.type) {
|
||||||
case GROUP: return [NSString stringWithFormat:@"%@:", self.name];
|
case GROUP: return [NSString stringWithFormat:@"%@:", self.anyName];
|
||||||
case FEED: return [NSString stringWithFormat:@"%@ (%@)", self.name, self.feed.meta.url];
|
case FEED: return [NSString stringWithFormat:@"%@ (%@)", self.anyName, self.feed.meta.url];
|
||||||
case SEPARATOR: return @"-------------";
|
case SEPARATOR: return @"-------------";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,6 @@ static int32_t const kDefaultFeedRefreshInterval = 30 * 60;
|
|||||||
- (void)setSucessfulWithResponse:(NSHTTPURLResponse*)response;
|
- (void)setSucessfulWithResponse:(NSHTTPURLResponse*)response;
|
||||||
// Setter
|
// Setter
|
||||||
- (void)setUrlIfChanged:(NSString*)url;
|
- (void)setUrlIfChanged:(NSString*)url;
|
||||||
- (BOOL)setRefreshAndSchedule:(int32_t)refresh;
|
- (void)setRefreshIfChanged:(int32_t)refresh;
|
||||||
- (void)scheduleNow:(NSTimeInterval)future;
|
- (void)scheduleNow:(NSTimeInterval)future;
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
[self scheduleNow:retryWaitTime];
|
[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 {
|
- (void)setSucessfulWithResponse:(NSHTTPURLResponse*)response {
|
||||||
self.errorCount = 0; // reset counter
|
self.errorCount = 0; // reset counter
|
||||||
NSDictionary *header = [response allHeaderFields];
|
NSDictionary *header = [response allHeaderFields];
|
||||||
@@ -68,26 +69,17 @@
|
|||||||
if (![self.url isEqualToString:url]) self.url = url;
|
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.
|
/// Set @c etag and @c modified attributes. Only values that differ will be updated.
|
||||||
- (void)setEtag:(NSString*)etag modified:(NSString*)modified {
|
- (void)setEtag:(NSString*)etag modified:(NSString*)modified {
|
||||||
if (![self.etag isEqualToString:etag]) self.etag = etag;
|
if (![self.etag isEqualToString:etag]) self.etag = etag;
|
||||||
if (![self.modified isEqualToString:modified]) self.modified = modified;
|
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.
|
/// Set next scheduled feed update or @c nil if @c refresh @c <= @c 0.
|
||||||
- (void)scheduleNow:(NSTimeInterval)future {
|
- (void)scheduleNow:(NSTimeInterval)future {
|
||||||
if (self.refresh <= 0) { // update deactivated; manually update with force update all
|
if (self.refresh <= 0) { // update deactivated; manually update with force update all
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ static int const dbFileVersion = 1; // update in case database structure changes
|
|||||||
|
|
||||||
// Feed update
|
// Feed update
|
||||||
+ (NSDate*)nextScheduledUpdate;
|
+ (NSDate*)nextScheduledUpdate;
|
||||||
+ (NSArray<Feed*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc;
|
+ (NSArray<Feed*>*)listOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc;
|
||||||
|
|
||||||
// Count elements
|
// Count elements
|
||||||
+ (BOOL)isEmpty;
|
+ (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<FeedGroup*>*)sortedFeedGroupsWithParent:(id)parent inContext:(NSManagedObjectContext*)moc;
|
||||||
+ (NSArray<FeedArticle*>*)sortedArticlesWithParent:(id)parent inContext:(NSManagedObjectContext*)moc;
|
+ (NSArray<FeedArticle*>*)sortedArticlesWithParent:(id)parent inContext:(NSManagedObjectContext*)moc;
|
||||||
+ (NSArray<Feed*>*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc;
|
+ (NSArray<Feed*>*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc;
|
||||||
+ (NSArray<Feed*>*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc;
|
|
||||||
+ (Feed*)feedWithIndexPath:(nonnull NSString*)path inContext:(NSManagedObjectContext*)moc;
|
+ (Feed*)feedWithIndexPath:(nonnull NSString*)path inContext:(NSManagedObjectContext*)moc;
|
||||||
+ (NSString*)urlForFeedWithIndexPath:(nonnull NSString*)path;
|
+ (NSString*)urlForFeedWithIndexPath:(nonnull NSString*)path;
|
||||||
|
|
||||||
@@ -57,4 +56,5 @@ static int const dbFileVersion = 1; // update in case database structure changes
|
|||||||
|
|
||||||
// Restore sound state
|
// Restore sound state
|
||||||
+ (void)cleanupAndShowAlert:(BOOL)flag;
|
+ (void)cleanupAndShowAlert:(BOOL)flag;
|
||||||
|
+ (NSUInteger)cleanupFavicons;
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -21,10 +21,12 @@
|
|||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
#import "StoreCoordinator.h"
|
#import "StoreCoordinator.h"
|
||||||
#import "Constants.h"
|
|
||||||
#import "NSFetchRequest+Ext.h"
|
|
||||||
#import "AppHook.h"
|
#import "AppHook.h"
|
||||||
|
#import "Constants.h"
|
||||||
|
#import "FaviconDownload.h"
|
||||||
#import "Feed+Ext.h"
|
#import "Feed+Ext.h"
|
||||||
|
#import "NSURL+Ext.h"
|
||||||
|
#import "NSFetchRequest+Ext.h"
|
||||||
|
|
||||||
@implementation StoreCoordinator
|
@implementation StoreCoordinator
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@
|
|||||||
NSError *error = nil;
|
NSError *error = nil;
|
||||||
if (context.hasChanges && ![context save:&error]) {
|
if (context.hasChanges && ![context save:&error]) {
|
||||||
// Customize this code block to include application-specific recovery steps.
|
// Customize this code block to include application-specific recovery steps.
|
||||||
[[NSApplication sharedApplication] presentError:error];
|
[NSApp presentError:error];
|
||||||
}
|
}
|
||||||
if (flag && context.parentContext) {
|
if (flag && context.parentContext) {
|
||||||
[self saveContext:context.parentContext andParent:flag];
|
[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.
|
@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];
|
NSFetchRequest *fr = [Feed fetchRequest];
|
||||||
if (!forceAll) {
|
if (!forceAll) {
|
||||||
// when fetching also get those feeds that would need update soon (now + 10s)
|
// when fetching also get those feeds that would need update soon (now + 2s)
|
||||||
[fr where:@"meta.scheduled <= %@", [NSDate dateWithTimeIntervalSinceNow:+10]];
|
[fr where:@"meta.scheduled <= %@", [NSDate dateWithTimeIntervalSinceNow:+2]];
|
||||||
}
|
}
|
||||||
return [fr fetchAllRows:moc];
|
return [fr fetchAllRows:moc];
|
||||||
}
|
}
|
||||||
@@ -156,11 +158,6 @@
|
|||||||
return [[[Feed fetchRequest] where:@"articles.@count == 0"] fetchAllRows:moc];
|
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.
|
/// @return Single @c Feed item where @c Feed.indexPath @c = @c path.
|
||||||
+ (Feed*)feedWithIndexPath:(nonnull NSString*)path inContext:(NSManagedObjectContext*)moc {
|
+ (Feed*)feedWithIndexPath:(nonnull NSString*)path inContext:(NSManagedObjectContext*)moc {
|
||||||
return [[[Feed fetchRequest] where:@"indexPath = %@", path] fetchFirst:moc];
|
return [[[Feed fetchRequest] where:@"indexPath = %@", path] fetchFirst:moc];
|
||||||
@@ -225,6 +222,7 @@
|
|||||||
|
|
||||||
#pragma mark - Restore Sound State
|
#pragma mark - Restore Sound State
|
||||||
|
|
||||||
|
/// Remove orphan core data entries with optional alert message of removed items count.
|
||||||
+ (void)cleanupAndShowAlert:(BOOL)flag {
|
+ (void)cleanupAndShowAlert:(BOOL)flag {
|
||||||
NSUInteger deleted = [self deleteUnreferenced];
|
NSUInteger deleted = [self deleteUnreferenced];
|
||||||
[self restoreFeedIndexPaths];
|
[self restoreFeedIndexPaths];
|
||||||
@@ -256,7 +254,6 @@
|
|||||||
NSManagedObjectContext *moc = [self getMainContext];
|
NSManagedObjectContext *moc = [self getMainContext];
|
||||||
deleted += [self batchDelete:Feed.entity nullAttribute:@"group" inContext:moc];
|
deleted += [self batchDelete:Feed.entity nullAttribute:@"group" inContext:moc];
|
||||||
deleted += [self batchDelete:FeedMeta.entity nullAttribute:@"feed" 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];
|
deleted += [self batchDelete:FeedArticle.entity nullAttribute:@"feed" inContext:moc];
|
||||||
if (deleted > 0) {
|
if (deleted > 0) {
|
||||||
[self saveContext:moc andParent:YES];
|
[self saveContext:moc andParent:YES];
|
||||||
@@ -291,4 +288,25 @@
|
|||||||
return [res.result unsignedIntegerValue];
|
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
|
@end
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
<attribute name="title" optional="YES" attributeType="String" syncable="YES"/>
|
<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="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="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"/>
|
<relationship name="meta" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="FeedMeta" inverseName="feed" inverseEntity="FeedMeta" syncable="YES"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="FeedArticle" representedClassName="FeedArticle" syncable="YES" codeGenerationType="class">
|
<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="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"/>
|
<relationship name="parent" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="FeedGroup" inverseName="children" inverseEntity="FeedGroup" syncable="YES"/>
|
||||||
</entity>
|
</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">
|
<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="errorCount" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
|
||||||
<attribute name="etag" optional="YES" attributeType="String" syncable="YES"/>
|
<attribute name="etag" optional="YES" attributeType="String" syncable="YES"/>
|
||||||
@@ -48,10 +43,9 @@
|
|||||||
<attribute name="value" optional="YES" attributeType="String" syncable="YES"/>
|
<attribute name="value" optional="YES" attributeType="String" syncable="YES"/>
|
||||||
</entity>
|
</entity>
|
||||||
<elements>
|
<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="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="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="FeedMeta" positionX="-348.02734375" positionY="136.89453125" width="128" height="150"/>
|
||||||
<element name="Options" positionX="-279" positionY="36" width="128" height="75"/>
|
<element name="Options" positionX="-279" positionY="36" width="128" height="75"/>
|
||||||
</elements>
|
</elements>
|
||||||
|
|||||||
47
baRSS/Feed Import/FaviconDownload.h
Normal file
47
baRSS/Feed Import/FaviconDownload.h
Normal 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
|
||||||
226
baRSS/Feed Import/FaviconDownload.m
Normal file
226
baRSS/Feed Import/FaviconDownload.m
Normal 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
|
||||||
63
baRSS/Feed Import/FeedDownload.h
Normal file
63
baRSS/Feed Import/FeedDownload.h
Normal 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
|
||||||
223
baRSS/Feed Import/FeedDownload.m
Normal file
223
baRSS/Feed Import/FeedDownload.m
Normal 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
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
#pragma mark - Helper
|
#pragma mark - Helper
|
||||||
|
|
||||||
/// Loop over all subviews and find the @c NSButton that is selected.
|
/// 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) {
|
for (NSButton *btn in view.subviews) {
|
||||||
if ([btn isKindOfClass:[NSButton class]] && btn.state == NSControlStateValueOn) {
|
if ([btn isKindOfClass:[NSButton class]] && btn.state == NSControlStateValueOn) {
|
||||||
return btn.tag;
|
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 {
|
- (void)enumerateFiles:(NSArray<NSURL*>*)files withBlock:(void(^)(RSOPMLItem *item))block finally:(nullable dispatch_block_t)finally {
|
||||||
dispatch_group_t group = dispatch_group_create();
|
dispatch_group_t group = dispatch_group_create();
|
||||||
for (NSURL *url in files) {
|
for (NSURL *url in files) {
|
||||||
if (finally) dispatch_group_enter(group);
|
dispatch_group_enter(group);
|
||||||
|
|
||||||
NSData *data = [NSData dataWithContentsOfURL:url];
|
NSData *data = [NSData dataWithContentsOfURL:url];
|
||||||
RSXMLData *xml = [[RSXMLData alloc] initWithData:data url:url];
|
RSXMLData *xml = [[RSXMLData alloc] initWithData:data url:url];
|
||||||
RSOPMLParser *parser = [RSOPMLParser parserWithXMLData:xml];
|
RSOPMLParser *parser = [RSOPMLParser parserWithXMLData:xml];
|
||||||
@@ -106,7 +105,7 @@ NS_INLINE NSInteger RadioGroupSelection(NSView *view) {
|
|||||||
block(itm);
|
block(itm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (finally) dispatch_group_leave(group);
|
dispatch_group_leave(group);
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
if (finally) dispatch_group_notify(group, dispatch_get_main_queue(), finally);
|
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];
|
NSData *xml = [doc XMLDataWithOptions:NSXMLNodePreserveAttributeOrder | NSXMLNodePrettyPrint];
|
||||||
[xml writeToURL:url options:NSDataWritingAtomic error:&error];
|
[xml writeToURL:url options:NSDataWritingAtomic error:&error];
|
||||||
}
|
}
|
||||||
if (error) {
|
if (error) [NSApp presentError:error];
|
||||||
[NSApp presentError:error];
|
|
||||||
}
|
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,8 +290,8 @@ NS_INLINE NSInteger RadioGroupSelection(NSView *view) {
|
|||||||
// dont add group node if hierarchical == NO
|
// dont add group node if hierarchical == NO
|
||||||
NSXMLElement *outline = [NSXMLElement elementWithName:@"outline"];
|
NSXMLElement *outline = [NSXMLElement elementWithName:@"outline"];
|
||||||
[parent addChild:outline];
|
[parent addChild:outline];
|
||||||
[outline addAttribute:[NSXMLNode attributeWithName:OPMLTitleKey stringValue:item.name]];
|
[outline addAttribute:[NSXMLNode attributeWithName:OPMLTitleKey stringValue:item.anyName]];
|
||||||
[outline addAttribute:[NSXMLNode attributeWithName:OPMLTextKey stringValue:item.name]];
|
[outline addAttribute:[NSXMLNode attributeWithName:OPMLTextKey stringValue:item.anyName]];
|
||||||
|
|
||||||
if (item.type == SEPARATOR) {
|
if (item.type == SEPARATOR) {
|
||||||
[outline addAttribute:[NSXMLNode attributeWithName:@"separator" stringValue:@"true"]]; // baRSS specific
|
[outline addAttribute:[NSXMLNode attributeWithName:@"separator" stringValue:@"true"]]; // baRSS specific
|
||||||
|
|||||||
@@ -37,7 +37,11 @@
|
|||||||
// Scheduling
|
// Scheduling
|
||||||
+ (void)scheduleNextFeed;
|
+ (void)scheduleNextFeed;
|
||||||
+ (void)forceUpdateAllFeeds;
|
+ (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
|
// Register for network change notifications
|
||||||
+ (void)registerNetworkChangeNotification;
|
+ (void)registerNetworkChangeNotification;
|
||||||
+ (void)unregisterNetworkChangeNotification;
|
+ (void)unregisterNetworkChangeNotification;
|
||||||
|
|||||||
@@ -22,24 +22,33 @@
|
|||||||
|
|
||||||
@import SystemConfiguration;
|
@import SystemConfiguration;
|
||||||
#import "UpdateScheduler.h"
|
#import "UpdateScheduler.h"
|
||||||
#import "WebFeed.h"
|
|
||||||
#import "Constants.h"
|
#import "Constants.h"
|
||||||
#import "StoreCoordinator.h"
|
#import "StoreCoordinator.h"
|
||||||
#import "NSDate+Ext.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 NSTimer *_timer;
|
||||||
static SCNetworkReachabilityRef _reachability = NULL;
|
static SCNetworkReachabilityRef _reachability = NULL;
|
||||||
static BOOL _isReachable = YES;
|
static BOOL _isReachable = YES;
|
||||||
static BOOL _updatePaused = NO;
|
static BOOL _updatePaused = NO;
|
||||||
static BOOL _nextUpdateIsForced = NO;
|
static BOOL _nextUpdateIsForced = NO;
|
||||||
|
static _Atomic(NSUInteger) _queueSize = 0;
|
||||||
|
|
||||||
@implementation UpdateScheduler
|
@implementation UpdateScheduler
|
||||||
|
|
||||||
#pragma mark - User Interaction
|
// ################################################################
|
||||||
|
// # MARK: - Getter & Setter -
|
||||||
|
// ################################################################
|
||||||
|
|
||||||
/// @return Number of feeds being currently downloaded.
|
/// @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.
|
/// @return Date when background update will fire. If updates are paused, date is @c distantFuture.
|
||||||
+ (NSDate *)dateScheduled { return _timer.fireDate; }
|
+ (NSDate *)dateScheduled { return _timer.fireDate; }
|
||||||
@@ -48,7 +57,7 @@ static BOOL _nextUpdateIsForced = NO;
|
|||||||
+ (BOOL)allowNetworkConnection { return (_isReachable && !_updatePaused); }
|
+ (BOOL)allowNetworkConnection { return (_isReachable && !_updatePaused); }
|
||||||
|
|
||||||
/// @return @c YES if batch update is running
|
/// @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.
|
/// @return @c YES if update is paused by user.
|
||||||
+ (BOOL)isPaused { return _updatePaused; }
|
+ (BOOL)isPaused { return _updatePaused; }
|
||||||
@@ -78,7 +87,7 @@ static BOOL _nextUpdateIsForced = NO;
|
|||||||
|
|
||||||
/// Update status. 'Updating X feeds …' or empty string if not updating.
|
/// Update status. 'Updating X feeds …' or empty string if not updating.
|
||||||
+ (NSString*)updatingXFeeds {
|
+ (NSString*)updatingXFeeds {
|
||||||
NSUInteger c = [WebFeed feedsInQueue];
|
NSUInteger c = _queueSize;
|
||||||
switch (c) {
|
switch (c) {
|
||||||
case 0: return @"";
|
case 0: return @"";
|
||||||
case 1: return NSLocalizedString(@"Updating 1 feed …", nil);
|
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 {
|
+ (void)scheduleNextFeed {
|
||||||
if (![self allowNetworkConnection]) // timer will restart once connection exists
|
if (![self allowNetworkConnection]) // timer will restart once connection exists
|
||||||
return;
|
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
|
return; // skip until called again
|
||||||
NSDate *nextTime = [StoreCoordinator nextScheduledUpdate]; // if nextTime = nil, then no feeds to update
|
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
|
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];
|
[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 {
|
+ (void)forceUpdateAllFeeds {
|
||||||
if (![self allowNetworkConnection]) // timer will restart once connection exists
|
if (![self allowNetworkConnection]) // timer will restart once connection exists
|
||||||
return;
|
return;
|
||||||
@@ -118,7 +123,7 @@ static BOOL _nextUpdateIsForced = NO;
|
|||||||
/**
|
/**
|
||||||
Set new @c .fireDate and @c .tolerance for update timer.
|
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 {
|
+ (void)scheduleTimer:(NSDate*)nextTime {
|
||||||
static dispatch_once_t onceToken;
|
static dispatch_once_t onceToken;
|
||||||
@@ -134,9 +139,7 @@ static BOOL _nextUpdateIsForced = NO;
|
|||||||
PostNotification(kNotificationScheduleTimerChanged, nil);
|
PostNotification(kNotificationScheduleTimerChanged, nil);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/// Called when schedule timer runs out (earliest @c .schedule date). Or if forced by user.
|
||||||
Called when schedule timer runs out (earliest @c .schedule date). Or if forced by user request.
|
|
||||||
*/
|
|
||||||
+ (void)updateTimerCallback {
|
+ (void)updateTimerCallback {
|
||||||
#ifdef DEBUG
|
#ifdef DEBUG
|
||||||
NSLog(@"fired");
|
NSLog(@"fired");
|
||||||
@@ -145,35 +148,115 @@ static BOOL _nextUpdateIsForced = NO;
|
|||||||
_nextUpdateIsForced = NO;
|
_nextUpdateIsForced = NO;
|
||||||
|
|
||||||
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
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.");
|
//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 ...
|
[StoreCoordinator saveContext:moc andParent:YES]; // save parents too ...
|
||||||
[moc reset];
|
[moc reset];
|
||||||
[self scheduleNextFeed]; // always reset the timer
|
[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 (![self allowNetworkConnection]) {
|
||||||
if (block) block();
|
if (block) block();
|
||||||
} else if (flag) {
|
return;
|
||||||
[WebFeed batchDownloadFeeds:list showErrorAlert:NO finally:block];
|
}
|
||||||
} else {
|
// Else: batch download
|
||||||
// TODO: add undo grouping?
|
atomic_fetch_add_explicit(&_queueSize, list.count, memory_order_relaxed);
|
||||||
[WebFeed setRequestsAreUrgent:YES];
|
PostNotification(kNotificationBackgroundUpdateInProgress, @(_queueSize));
|
||||||
[WebFeed batchDownloadFeeds:list showErrorAlert:YES finally:^{
|
dispatch_group_t group = dispatch_group_create();
|
||||||
[WebFeed setRequestsAreUrgent:NO];
|
for (Feed *f in list) {
|
||||||
if (block) block();
|
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.
|
/// Set callback on @c self to listen for network reachability changes.
|
||||||
+ (void)registerNetworkChangeNotification {
|
+ (void)registerNetworkChangeNotification {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
*/
|
|
||||||
@@ -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
|
|
||||||
27
baRSS/Helper/NSError+Ext.h
Normal file
27
baRSS/Helper/NSError+Ext.h
Normal 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
113
baRSS/Helper/NSError+Ext.m
Normal 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
31
baRSS/Helper/NSURL+Ext.h
Normal 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
76
baRSS/Helper/NSURL+Ext.m
Normal 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
|
||||||
29
baRSS/Helper/NSURLRequest+Ext.h
Normal file
29
baRSS/Helper/NSURLRequest+Ext.h
Normal 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
|
||||||
97
baRSS/Helper/NSURLRequest+Ext.m
Normal file
97
baRSS/Helper/NSURLRequest+Ext.m
Normal 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
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>11519</string>
|
<string>13197</string>
|
||||||
<key>LSApplicationCategoryType</key>
|
<key>LSApplicationCategoryType</key>
|
||||||
<string>public.app-category.news</string>
|
<string>public.app-category.news</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
|||||||
@@ -20,20 +20,25 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
|
@import RSXML;
|
||||||
#import "ModalFeedEdit.h"
|
#import "ModalFeedEdit.h"
|
||||||
#import "WebFeed.h"
|
#import "ModalFeedEditView.h"
|
||||||
#import "StoreCoordinator.h"
|
#import "RefreshStatisticsView.h"
|
||||||
|
#import "Constants.h"
|
||||||
|
#import "FeedDownload.h"
|
||||||
|
#import "FaviconDownload.h"
|
||||||
#import "Feed+Ext.h"
|
#import "Feed+Ext.h"
|
||||||
#import "FeedMeta+Ext.h"
|
#import "FeedMeta+Ext.h"
|
||||||
#import "FeedGroup+Ext.h"
|
#import "FeedGroup+Ext.h"
|
||||||
#import "ModalFeedEditView.h"
|
|
||||||
#import "RefreshStatisticsView.h"
|
|
||||||
#import "NSDate+Ext.h"
|
|
||||||
#import "NSView+Ext.h"
|
#import "NSView+Ext.h"
|
||||||
|
#import "NSDate+Ext.h"
|
||||||
|
#import "NSURL+Ext.h"
|
||||||
|
|
||||||
|
// ################################################################
|
||||||
#pragma mark - ModalEditDialog -
|
// #
|
||||||
|
// # MARK: - ModalEditDialog -
|
||||||
|
// #
|
||||||
|
// ################################################################
|
||||||
|
|
||||||
@interface ModalEditDialog() <NSWindowDelegate>
|
@interface ModalEditDialog() <NSWindowDelegate>
|
||||||
@property (strong) FeedGroup *feedGroup;
|
@property (strong) FeedGroup *feedGroup;
|
||||||
@@ -62,21 +67,20 @@
|
|||||||
}
|
}
|
||||||
@end
|
@end
|
||||||
|
|
||||||
|
// ################################################################
|
||||||
|
// #
|
||||||
|
// # MARK: - ModalFeedEdit -
|
||||||
|
// #
|
||||||
|
// ################################################################
|
||||||
|
|
||||||
#pragma mark - ModalFeedEdit -
|
@interface ModalFeedEdit() <FeedDownloadDelegate, RefreshIntervalButtonDelegate, FaviconDownloadDelegate>
|
||||||
|
|
||||||
|
|
||||||
@interface ModalFeedEdit() <RefreshIntervalButtonDelegate>
|
|
||||||
@property (strong) IBOutlet ModalFeedEditView *view; // override
|
@property (strong) IBOutlet ModalFeedEditView *view; // override
|
||||||
|
|
||||||
@property (strong) RefreshStatisticsView *statisticsView;
|
|
||||||
|
|
||||||
@property (copy) NSString *previousURL; // check if changed and avoid multiple download
|
@property (copy) NSString *previousURL; // check if changed and avoid multiple download
|
||||||
@property (copy) NSString *faviconURL;
|
@property (strong) NSURL *faviconFile;
|
||||||
@property (strong) NSError *feedError; // download error or xml parser error
|
@property (strong) FeedDownload *memFeed;
|
||||||
@property (strong) RSParsedFeed *feedResult; // parsed result
|
@property (weak) FaviconDownload *memIcon;
|
||||||
@property (strong) NSHTTPURLResponse *httpResponse;
|
@property (strong) RefreshStatisticsView *statisticsView;
|
||||||
@property (assign) BOOL didDownloadFeed; // check if feed articles need update
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@implementation ModalFeedEdit
|
@implementation ModalFeedEdit
|
||||||
@@ -91,12 +95,11 @@
|
|||||||
[self populateTextFields:self.feedGroup];
|
[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 {
|
- (void)populateTextFields:(FeedGroup*)fg {
|
||||||
if (!fg || [fg hasChanges]) return; // hasChanges is true only if newly created
|
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.view.url.objectValue = fg.feed.meta.url;
|
||||||
self.previousURL = self.view.url.stringValue;
|
self.previousURL = self.view.url.stringValue;
|
||||||
self.view.favicon.image = [fg.feed iconImage16];
|
self.view.favicon.image = [fg.feed iconImage16];
|
||||||
@@ -104,6 +107,10 @@
|
|||||||
[self statsForCoreDataObject];
|
[self statsForCoreDataObject];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (void)dealloc {
|
||||||
|
[self.faviconFile remove]; // Delete temporary favicon (if still exists)
|
||||||
|
}
|
||||||
|
|
||||||
#pragma mark - Edit Feed Data
|
#pragma mark - Edit Feed Data
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,63 +118,42 @@
|
|||||||
Set @c scheduled to a new date if refresh interval was changed.
|
Set @c scheduled to a new date if refresh interval was changed.
|
||||||
*/
|
*/
|
||||||
- (void)applyChangesToCoreDataObject {
|
- (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];
|
[self.feedGroup setNameIfChanged:self.view.name.stringValue];
|
||||||
FeedMeta *meta = feed.meta;
|
[f.meta setRefreshIfChanged:intv];
|
||||||
[meta setUrlIfChanged:self.previousURL];
|
if (self.memFeed) {
|
||||||
[meta setRefreshAndSchedule:[NSDate intervalForPopup:self.view.refreshUnit andField:self.view.refreshNum]];
|
[self.memFeed copyValuesTo:f ignoreError:YES];
|
||||||
// updateTimer will be scheduled once preferences is closed
|
[f setNewIcon:self.faviconFile]; // only if downloaded anything (nil deletes icon!)
|
||||||
if (self.didDownloadFeed) {
|
self.faviconFile = nil;
|
||||||
[meta setSucessfulWithResponse:self.httpResponse];
|
|
||||||
[feed updateWithRSS:self.feedResult postUnreadCountChange:YES];
|
|
||||||
[feed setIconImage:self.view.favicon.image];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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).
|
Prepare UI (nullify results and start @c ProgressIndicator ).
|
||||||
Also disable 'Done' button during download and re-enable after all downloads are finished.
|
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.modalSheet setDoneEnabled:NO]; // prevent user from closing the dialog during download
|
||||||
[self.view.spinnerURL startAnimation:nil];
|
[self.view.spinnerURL startAnimation:nil];
|
||||||
[self.view.spinnerName startAnimation:nil];
|
[self.view.spinnerName startAnimation:nil];
|
||||||
self.view.favicon.image = nil;
|
self.view.favicon.image = nil;
|
||||||
self.view.warningButton.hidden = YES;
|
self.view.warningButton.hidden = YES;
|
||||||
self.didDownloadFeed = NO;
|
// User didn't change title since last fetch. Will be pre-filled with new title after download
|
||||||
// Assuming the user has not changed title since the last fetch.
|
if ([self.view.name.stringValue isEqualToString:self.view.name.placeholderString]) {
|
||||||
// Reset to "" because after download it will be pre-filled with new feed title
|
|
||||||
if ([self.view.name.stringValue isEqualToString:self.feedResult.title]) {
|
|
||||||
self.view.name.stringValue = @"";
|
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;
|
self.previousURL = self.view.url.stringValue;
|
||||||
}
|
self.memFeed = [[FeedDownload withURL:self.previousURL] startWithDelegate:self];
|
||||||
|
|
||||||
/**
|
|
||||||
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];
|
|
||||||
}];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -176,12 +162,7 @@
|
|||||||
|
|
||||||
@return Either URL string or @c nil if user canceled the selection.
|
@return Either URL string or @c nil if user canceled the selection.
|
||||||
*/
|
*/
|
||||||
- (NSString*)letUserChooseXmlUrlFromList:(NSArray<RSHTMLMetadataFeedLink*> *)list {
|
- (NSString*)feedDownload:(FeedDownload*)sender selectFeedFromList:(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;
|
|
||||||
}
|
|
||||||
NSMenu *menu = [[NSMenu alloc] initWithTitle:NSLocalizedString(@"Choose feed menu", nil)];
|
NSMenu *menu = [[NSMenu alloc] initWithTitle:NSLocalizedString(@"Choose feed menu", nil)];
|
||||||
menu.autoenablesItems = NO;
|
menu.autoenablesItems = NO;
|
||||||
for (RSHTMLMetadataFeedLink *fl in list) {
|
for (RSHTMLMetadataFeedLink *fl in list) {
|
||||||
@@ -196,62 +177,53 @@
|
|||||||
return nil; // user selection canceled
|
return nil; // user selection canceled
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/// If URL was redirected, replace original text field value with new one. (e.g., https redirect)
|
||||||
Update UI TextFields with downloaded values.
|
- (void)feedDownload:(FeedDownload*)sender urlRedirected:(NSString*)newURL {
|
||||||
Title will be updated if TextField is empty. URL on redirect.
|
if (!sender.error) {
|
||||||
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 the url has changed and there is an 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.
|
// 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)
|
// 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.
|
// 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;
|
/// Update UI TextFields with downloaded values. Title updated if TextField is empty, URL if redirect.
|
||||||
if (parsedTitle.length > 0 && [self.view.name.stringValue isEqualToString:@""]) {
|
- (void)feedDownloadDidFinish:(FeedDownload*)sender {
|
||||||
self.view.name.stringValue = parsedTitle; // no damage to replace an empty string
|
// 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)
|
// TODO: user preference to automatically select refresh interval (selection: None,min,max,avg,median)
|
||||||
[self statsForDownloadObject];
|
[self statsForDownloadObject:sender.xmlfeed.articles];
|
||||||
// 4. Continue with favicon download (or finish with error)
|
BOOL hasError = (sender.error != nil);
|
||||||
self.view.favicon.hidden = hasError;
|
self.view.favicon.hidden = hasError;
|
||||||
self.view.warningButton.hidden = !hasError;
|
self.view.warningButton.hidden = !hasError;
|
||||||
if (hasError) {
|
// Start favicon download
|
||||||
[self finishDownloadWithFavicon];
|
if (hasError)
|
||||||
} else {
|
[self downloadComplete];
|
||||||
if (!self.faviconURL)
|
else
|
||||||
self.faviconURL = self.feedResult.link;
|
self.memIcon = [[sender faviconDownload] startWithDelegate:self];
|
||||||
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];
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
The last step of the download process.
|
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 {
|
- (void)faviconDownload:(FaviconDownload*)sender didFinish:(nullable NSURL*)path {
|
||||||
if (self.modalSheet.didCloseAndCancel)
|
// Create image from favicon temporary file location or default icon if no favicon exists.
|
||||||
return;
|
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.view.spinnerURL stopAnimation:nil];
|
||||||
[self.modalSheet setDoneEnabled:YES];
|
[self.modalSheet setDoneEnabled:YES];
|
||||||
}
|
}
|
||||||
@@ -259,15 +231,15 @@
|
|||||||
#pragma mark - Feed Statistics
|
#pragma mark - Feed Statistics
|
||||||
|
|
||||||
/// Perform statistics on newly downloaded feed item
|
/// Perform statistics on newly downloaded feed item
|
||||||
- (void)statsForDownloadObject {
|
- (void)statsForDownloadObject:(NSArray<RSParsedArticle*>*)articles {
|
||||||
NSMutableArray<NSDate*> *arr = [NSMutableArray arrayWithCapacity:self.feedResult.articles.count];
|
NSMutableArray<NSDate*> *arr = [NSMutableArray arrayWithCapacity:articles.count];
|
||||||
for (RSParsedArticle *a in self.feedResult.articles) {
|
for (RSParsedArticle *a in articles) {
|
||||||
NSDate *d = a.datePublished;
|
NSDate *d = a.datePublished;
|
||||||
if (!d) d = a.dateModified;
|
if (!d) d = a.dateModified;
|
||||||
if (!d) continue;
|
if (!d) continue;
|
||||||
[arr addObject:d];
|
[arr addObject:d];
|
||||||
}
|
}
|
||||||
[self appendViewWithFeedStatistics:arr count:self.feedResult.articles.count];
|
[self appendViewWithFeedStatistics:arr count:articles.count];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Perform statistics on stored core data object
|
/// Perform statistics on stored core data object
|
||||||
@@ -301,8 +273,10 @@
|
|||||||
|
|
||||||
|
|
||||||
/// Window delegate will be only called on button 'Done'.
|
/// Window delegate will be only called on button 'Done'.
|
||||||
- (BOOL)windowShouldClose:(NSWindow *)sender {
|
- (BOOL)windowShouldClose:(ModalSheet*)sender {
|
||||||
if (![self.previousURL isEqualToString:self.view.url.stringValue]) {
|
if (sender.didTapCancel) {
|
||||||
|
[self cancelDownloads];
|
||||||
|
} else if (![self.previousURL isEqualToString:self.view.url.stringValue]) { // 'Done' button
|
||||||
[[NSNotificationCenter defaultCenter] postNotificationName:NSControlTextDidEndEditingNotification object:self.view.url];
|
[[NSNotificationCenter defaultCenter] postNotificationName:NSControlTextDidEndEditingNotification object:self.view.url];
|
||||||
return NO;
|
return NO;
|
||||||
}
|
}
|
||||||
@@ -311,7 +285,7 @@
|
|||||||
|
|
||||||
/// Whenever the user finished entering the url (return key or focus change) perform a download request.
|
/// Whenever the user finished entering the url (return key or focus change) perform a download request.
|
||||||
- (void)controlTextDidEndEditing:(NSNotification*)obj {
|
- (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]) {
|
if (![self.previousURL isEqualToString:self.view.url.stringValue]) {
|
||||||
[self downloadRSS];
|
[self downloadRSS];
|
||||||
}
|
}
|
||||||
@@ -320,15 +294,18 @@
|
|||||||
|
|
||||||
/// Warning button next to url text field. Will be visible if an error occurs during download.
|
/// Warning button next to url text field. Will be visible if an error occurs during download.
|
||||||
- (void)didClickWarningButton:(NSButton*)sender {
|
- (void)didClickWarningButton:(NSButton*)sender {
|
||||||
if (!self.feedError)
|
NSError *err = self.memFeed.error;
|
||||||
return;
|
if (!err) return;
|
||||||
|
|
||||||
// show reload button if server is temporarily offline (any 5xx server error)
|
// 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;
|
self.view.warningReload.hidden = !serverError;
|
||||||
|
|
||||||
// set error description as text
|
// 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
|
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.width += 2 * self.view.warningText.frame.origin.x; // the padding
|
||||||
newSize.height += 2 * self.view.warningText.frame.origin.y;
|
newSize.height += 2 * self.view.warningText.frame.origin.y;
|
||||||
@@ -345,9 +322,11 @@
|
|||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
|
// ################################################################
|
||||||
#pragma mark - ModalGroupEdit -
|
// #
|
||||||
|
// # MARK: - ModalGroupEdit -
|
||||||
|
// #
|
||||||
|
// ################################################################
|
||||||
|
|
||||||
@implementation ModalGroupEdit
|
@implementation ModalGroupEdit
|
||||||
/// Init view and set group name if edeting an already existing object.
|
/// Init view and set group name if edeting an already existing object.
|
||||||
|
|||||||
@@ -144,9 +144,8 @@ const NSPasteboardType dragReorder = @"de.relikd.baRSS.drag-reorder";
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Get list of feeds, and root level selection
|
// Get list of feeds, and root level selection
|
||||||
NSUInteger count = moc.insertedObjects.count;
|
NSMutableArray<NSIndexPath*> *selection = [NSMutableArray array];
|
||||||
NSMutableArray<NSIndexPath*> *selection = [NSMutableArray arrayWithCapacity:count];
|
NSMutableArray<Feed*> *feedsList = [NSMutableArray array];
|
||||||
NSMutableArray<Feed*> *feedsList = [NSMutableArray arrayWithCapacity:count];
|
|
||||||
for (__kindof NSManagedObject *obj in moc.insertedObjects) {
|
for (__kindof NSManagedObject *obj in moc.insertedObjects) {
|
||||||
if ([obj isKindOfClass:[Feed class]]) {
|
if ([obj isKindOfClass:[Feed class]]) {
|
||||||
[feedsList addObject:obj]; // list of feeds that need download
|
[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)
|
if (selection.count > 0)
|
||||||
[self.dataStore setSelectionIndexPaths:[selection sortedArrayUsingSelector:@selector(compare:)]];
|
[self.dataStore setSelectionIndexPaths:[selection sortedArrayUsingSelector:@selector(compare:)]];
|
||||||
|
|
||||||
[UpdateScheduler downloadList:feedsList background:NO finally:^{
|
[UpdateScheduler downloadList:feedsList userInitiated:YES finally:^{
|
||||||
[self endCoreDataChangeUndoEmpty:NO forceUndo:NO];
|
[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];
|
[UpdateScheduler scheduleNextFeed];
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
@@ -243,7 +244,7 @@ const NSPasteboardType dragReorder = @"de.relikd.baRSS.drag-reorder";
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
NS_INLINE BOOL IndexPathIsChildOfParent(NSIndexPath *child, NSIndexPath *parent) {
|
static inline BOOL IndexPathIsChildOfParent(NSIndexPath *child, NSIndexPath *parent) {
|
||||||
while (child.length > parent.length)
|
while (child.length > parent.length)
|
||||||
child = [child indexPathByRemovingLastIndex];
|
child = [child indexPathByRemovingLastIndex];
|
||||||
return [child isEqualTo:parent];
|
return [child isEqualTo:parent];
|
||||||
|
|||||||
@@ -47,9 +47,9 @@
|
|||||||
- (void)viewDidLoad {
|
- (void)viewDidLoad {
|
||||||
[super viewDidLoad];
|
[super viewDidLoad];
|
||||||
// Register for notifications
|
// Register for notifications
|
||||||
RegisterNotification(kNotificationFeedUpdated, @selector(feedUpdated:), self);
|
RegisterNotification(kNotificationArticlesUpdated, @selector(feedUpdated:), self);
|
||||||
RegisterNotification(kNotificationFeedIconUpdated, @selector(feedUpdated:), self);
|
RegisterNotification(kNotificationFeedIconUpdated, @selector(feedUpdated:), self);
|
||||||
RegisterNotification(kNotificationGroupInserted, @selector(groupInserted:), self);
|
RegisterNotification(kNotificationFeedGroupInserted, @selector(feedGroupInserted:), self);
|
||||||
// Status bar
|
// Status bar
|
||||||
RegisterNotification(kNotificationScheduleTimerChanged, @selector(updateStatusInfo), self);
|
RegisterNotification(kNotificationScheduleTimerChanged, @selector(updateStatusInfo), self);
|
||||||
RegisterNotification(kNotificationNetworkStatusChanged, @selector(updateStatusInfo), self);
|
RegisterNotification(kNotificationNetworkStatusChanged, @selector(updateStatusInfo), self);
|
||||||
@@ -58,6 +58,8 @@
|
|||||||
|
|
||||||
- (void)dealloc {
|
- (void)dealloc {
|
||||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||||
|
NSUInteger c = [StoreCoordinator cleanupFavicons];
|
||||||
|
if (c > 0) NSLog(@"Removed %lu unreferenced favicons", c);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize status info timer
|
/// Initialize status info timer
|
||||||
@@ -97,10 +99,8 @@
|
|||||||
self.dataStore.sortDescriptors = [NSArray arrayWithObject:[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]];
|
self.dataStore.sortDescriptors = [NSArray arrayWithObject:[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]];
|
||||||
|
|
||||||
NSError *error;
|
NSError *error;
|
||||||
BOOL ok = [self.dataStore fetchWithRequest:nil merge:NO error:&error];
|
[self.dataStore fetchWithRequest:nil merge:NO error:&error];
|
||||||
if (!ok || error) {
|
if (error) [NSApp presentError:error];
|
||||||
[[NSApplication sharedApplication] presentError:error];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Callback method fired when feed is inserted via a 'feed://' url
|
/// Callback method fired when feed is inserted via a 'feed://' url
|
||||||
- (void)groupInserted:(NSNotification*)notify {
|
- (void)feedGroupInserted:(NSNotification*)notify {
|
||||||
[self.dataStore fetch:self];
|
[self.dataStore fetch:self];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +312,7 @@
|
|||||||
}
|
}
|
||||||
if ([self endCoreDataChangeUndoEmpty:YES forceUndo:(returnCode != NSModalResponseOK)]) {
|
if ([self endCoreDataChangeUndoEmpty:YES forceUndo:(returnCode != NSModalResponseOK)]) {
|
||||||
if (!flag) [UpdateScheduler scheduleNextFeed]; // only for feed edit
|
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
|
||||||
}
|
}
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ NSUserInterfaceItemIdentifier const CustomCellName = @"NameColumnCell";
|
|||||||
}
|
}
|
||||||
|
|
||||||
- (void)setObjectValue:(FeedGroup*)fg {
|
- (void)setObjectValue:(FeedGroup*)fg {
|
||||||
self.textField.objectValue = fg.name;
|
self.textField.objectValue = fg.anyName;
|
||||||
self.imageView.image = fg.iconImage16;
|
self.imageView.image = fg.iconImage16;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
@import Cocoa;
|
@import Cocoa;
|
||||||
|
|
||||||
@interface ModalSheet : NSPanel
|
@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)initWithContentRect:(NSRect)contentRect styleMask:(NSWindowStyleMask)style backing:(NSBackingStoreType)backingStoreType defer:(BOOL)flag NS_UNAVAILABLE;
|
||||||
- (instancetype)initWithView:(NSView*)content NS_DESIGNATED_INITIALIZER;
|
- (instancetype)initWithView:(NSView*)content NS_DESIGNATED_INITIALIZER;
|
||||||
|
|||||||
@@ -81,14 +81,14 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
Called after user has clicked the 'Done' (Return) or 'Cancel' (Esc) button.
|
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 {
|
- (void)didTapButton:(NSButton*)sender {
|
||||||
BOOL successful = (sender.tag == 42); // 'Done' button
|
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;
|
return;
|
||||||
}
|
}
|
||||||
_didCloseAndCancel = !successful;
|
|
||||||
// Save modal view width for next time
|
// Save modal view width for next time
|
||||||
CGFloat w = NSWidth(self.contentView.frame) - 2 * PAD_WIN;
|
CGFloat w = NSWidth(self.contentView.frame) - 2 * PAD_WIN;
|
||||||
[[NSUserDefaults standardUserDefaults] setInteger:(NSInteger)w forKey:@"modalSheetWidth"];
|
[[NSUserDefaults standardUserDefaults] setInteger:(NSInteger)w forKey:@"modalSheetWidth"];
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
#import "MapUnreadTotal.h"
|
#import "MapUnreadTotal.h"
|
||||||
#import "StoreCoordinator.h"
|
#import "StoreCoordinator.h"
|
||||||
#import "Feed+Ext.h"
|
#import "Feed+Ext.h"
|
||||||
|
#import "FeedGroup+Ext.h"
|
||||||
#import "FeedArticle+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
|
// TODO: move unread counts to status item and keep in sync when changing feeds in preferences
|
||||||
self.unreadMap = [[MapUnreadTotal alloc] initWithCoreData: [StoreCoordinator countAggregatedUnread]];
|
self.unreadMap = [[MapUnreadTotal alloc] initWithCoreData: [StoreCoordinator countAggregatedUnread]];
|
||||||
// Register for notifications
|
// Register for notifications
|
||||||
RegisterNotification(kNotificationFeedUpdated, @selector(feedUpdated:), self);
|
RegisterNotification(kNotificationArticlesUpdated, @selector(articlesUpdated:), self);
|
||||||
RegisterNotification(kNotificationFeedIconUpdated, @selector(feedIconUpdated:), self);
|
RegisterNotification(kNotificationFeedIconUpdated, @selector(feedIconUpdated:), self);
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
@@ -129,21 +130,15 @@
|
|||||||
- (void)updateFeedMenuItem:(NSManagedObjectID*)oid withBlock:(void(^)(Feed *feed, NSMenuItem *item))block {
|
- (void)updateFeedMenuItem:(NSManagedObjectID*)oid withBlock:(void(^)(Feed *feed, NSMenuItem *item))block {
|
||||||
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
||||||
Feed *feed = [moc objectWithID:oid];
|
Feed *feed = [moc objectWithID:oid];
|
||||||
if (![feed isKindOfClass:[Feed class]]) {
|
if ([feed isKindOfClass:[Feed class]]) {
|
||||||
[moc reset];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
NSMenuItem *item = [self.statusItem.mainMenu deepestItemWithPath:feed.indexPath];
|
NSMenuItem *item = [self.statusItem.mainMenu deepestItemWithPath:feed.indexPath];
|
||||||
if (!item) {
|
if (item) block(feed, item);
|
||||||
[moc reset];
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
block(feed, item);
|
|
||||||
[moc reset];
|
[moc reset];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Callback method fired when feed has been updated in the background.
|
/// 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) {
|
[self updateFeedMenuItem:notify.object withBlock:^(Feed *feed, NSMenuItem *item) {
|
||||||
// 1. update in-memory unread count
|
// 1. update in-memory unread count
|
||||||
UnreadTotal *updated = [UnreadTotal new];
|
UnreadTotal *updated = [UnreadTotal new];
|
||||||
@@ -154,8 +149,7 @@
|
|||||||
[self.unreadMap updateAllCounts:updated forPath:feed.indexPath];
|
[self.unreadMap updateAllCounts:updated forPath:feed.indexPath];
|
||||||
// 2. rebuild articles menu if it is open
|
// 2. rebuild articles menu if it is open
|
||||||
if (item.submenu.isFeedMenu) { // menu item is visible
|
if (item.submenu.isFeedMenu) { // menu item is visible
|
||||||
if (feed.group.name)
|
item.title = feed.group.anyName; // will replace (no title)
|
||||||
item.title = feed.group.name; // will replace (no title)
|
|
||||||
item.image = [feed iconImage16];
|
item.image = [feed iconImage16];
|
||||||
item.enabled = (feed.articles.count > 0);
|
item.enabled = (feed.articles.count > 0);
|
||||||
if (item.submenu.numberOfItems > 0) { // replace articles menu
|
if (item.submenu.numberOfItems > 0) { // replace articles menu
|
||||||
|
|||||||
Reference in New Issue
Block a user