From 4075073d1bb99528b2e106b1b851141a5a8e1d80 Mon Sep 17 00:00:00 2001 From: relikd Date: Sun, 15 Sep 2019 23:27:01 +0200 Subject: [PATCH] Refactoring feed download + favicon cache --- CHANGELOG.md | 11 +- baRSS.xcodeproj/project.pbxproj | 40 +- baRSS/AppHook.m | 39 +- baRSS/Constants.h | 10 +- baRSS/Core Data/Feed+Ext.h | 8 +- baRSS/Core Data/Feed+Ext.m | 58 ++- baRSS/Core Data/FeedGroup+Ext.h | 5 +- baRSS/Core Data/FeedGroup+Ext.m | 35 +- baRSS/Core Data/FeedMeta+Ext.h | 2 +- baRSS/Core Data/FeedMeta+Ext.m | 20 +- baRSS/Core Data/StoreCoordinator.h | 4 +- baRSS/Core Data/StoreCoordinator.m | 42 +- .../DBv1.xcdatamodel/contents | 8 +- baRSS/Feed Import/FaviconDownload.h | 47 ++ baRSS/Feed Import/FaviconDownload.m | 226 ++++++++++ baRSS/Feed Import/FeedDownload.h | 63 +++ baRSS/Feed Import/FeedDownload.m | 223 ++++++++++ baRSS/Feed Import/OpmlFile.m | 15 +- baRSS/Feed Import/UpdateScheduler.h | 6 +- baRSS/Feed Import/UpdateScheduler.m | 157 +++++-- baRSS/Feed Import/WebFeed.h | 52 --- baRSS/Feed Import/WebFeed.m | 409 ------------------ baRSS/Helper/NSError+Ext.h | 27 ++ baRSS/Helper/NSError+Ext.m | 113 +++++ baRSS/Helper/NSURL+Ext.h | 31 ++ baRSS/Helper/NSURL+Ext.m | 76 ++++ baRSS/Helper/NSURLRequest+Ext.h | 29 ++ baRSS/Helper/NSURLRequest+Ext.m | 97 +++++ baRSS/Info.plist | 2 +- baRSS/Preferences/Feeds Tab/ModalFeedEdit.m | 245 +++++------ .../Feeds Tab/SettingsFeeds+DragDrop.m | 11 +- baRSS/Preferences/Feeds Tab/SettingsFeeds.m | 16 +- .../Preferences/Feeds Tab/SettingsFeedsView.m | 2 +- baRSS/Preferences/Helper/ModalSheet.h | 2 +- baRSS/Preferences/Helper/ModalSheet.m | 6 +- baRSS/Status Bar Menu/BarMenu.m | 20 +- 36 files changed, 1360 insertions(+), 797 deletions(-) create mode 100644 baRSS/Feed Import/FaviconDownload.h create mode 100644 baRSS/Feed Import/FaviconDownload.m create mode 100644 baRSS/Feed Import/FeedDownload.h create mode 100644 baRSS/Feed Import/FeedDownload.m delete mode 100644 baRSS/Feed Import/WebFeed.h delete mode 100644 baRSS/Feed Import/WebFeed.m create mode 100644 baRSS/Helper/NSError+Ext.h create mode 100644 baRSS/Helper/NSError+Ext.m create mode 100644 baRSS/Helper/NSURL+Ext.h create mode 100644 baRSS/Helper/NSURL+Ext.m create mode 100644 baRSS/Helper/NSURLRequest+Ext.h create mode 100644 baRSS/Helper/NSURLRequest+Ext.m diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e0042d..4cfb25f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project does adhere to [Semantic Versioning](https://semver.org/spec/v2 - Associate OPML files (double click and right click actions in Finder) - Quick Look preview for OPML files - *Adding feed:* 5xx server errors have a reload button which will initiate a new download with the same URL +- *Adding feed:* Empty feed title will automatically reuse title from xml file (even if xml title changes) - *Adding feed:* `⌘R` will reload the same URL - *Settings, Feeds:* `⌘R` will reload the data source - *Settings, Feeds:* Refresh interval string localizations @@ -26,11 +27,12 @@ and this project does adhere to [Semantic Versioning](https://semver.org/spec/v2 - Config URL scheme `barss:` with `open/preferences` and `config/fixcache` ### Fixed -- *Adding feed:* Show users any 5xx server error response and extracted failure reason +- *Adding feed:* Show proper HTTP status code error message (4xx and 5xx) +- *Adding feed:* Show (HTML) extracted failure reason for 5xx server errors - *Adding feed:* If URLs can't be resolved in the first run (5xx error), try a second time. E.g., `Done` click (issue: #5) - *Adding feed:* Prefer favicons with size `32x32` -- *Adding feed:* Inserting feeds when offline will postpone download until network is reachable again -- *Adding feed:* Inserting feeds when paused will postpone download until unpaused +- *Adding feed:* Inserting feeds when offline/paused will postpone download until network is reachable again +- *Adding feed:* `Cancel` will indeed cancel download, not just continue and ignore results - *Settings, Feeds:* Actions `delete` and `edit` use clicked items instead of selected items - *Settings, Feeds:* Status info with accurate download count (instead of `Updating feeds …`) - *Settings, Feeds:* Status info shows `No network connection` and `Updates paused` @@ -47,12 +49,13 @@ and this project does adhere to [Semantic Versioning](https://semver.org/spec/v2 - *Settings, Feeds:* Single add button for feeds, groups, and separators - *Settings, Feeds:* Always append new items at the end - *Settings, General*: Moved `Fix cache` button to `About` text section -- *Settings, General*: Changing default feed reader is prohibited within sandbox +- *Settings, General*: Changing default feed reader is prohibited within sandbox - *Status Bar Menu*: Show `(no title)` instead of `(error)` - *Status Bar Menu*: `Update all feeds` will show error alerts for broken URLs - *UI:* Interface builder files replaced with code equivalent - *UI:* Mark unread articles with blue dot, instead of tick mark - *DB*: New table for options. E.g., what app version modified the database +- Dropping database table `FeedIcon` in favor of image files cache ## [0.9.4] - 2019-04-02 diff --git a/baRSS.xcodeproj/project.pbxproj b/baRSS.xcodeproj/project.pbxproj index f74f2da..07211d1 100644 --- a/baRSS.xcodeproj/project.pbxproj +++ b/baRSS.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 544DCCB9212A2B4D002DBC46 /* RSXML.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 544DCCB8212A2B4D002DBC46 /* RSXML.framework */; }; 544DCCBA212A2B4D002DBC46 /* RSXML.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 544DCCB8212A2B4D002DBC46 /* RSXML.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 544DCCBE212A2B6F002DBC46 /* RSXML.framework.dSYM in CopyFiles */ = {isa = PBXBuildFile; fileRef = 544DCCBD212A2B6F002DBC46 /* RSXML.framework.dSYM */; }; + 54501010230E9C8600F0B165 /* FeedDownload.m in Sources */ = {isa = PBXBuildFile; fileRef = 5450100F230E9C8600F0B165 /* FeedDownload.m */; }; 546A6A2922C583390034E806 /* SettingsGeneralView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D857D122802309001BA1C8 /* SettingsGeneralView.m */; }; 546A6A2C22C584AF0034E806 /* SettingsAppearanceView.m in Sources */ = {isa = PBXBuildFile; fileRef = 546A6A2A22C584AF0034E806 /* SettingsAppearanceView.m */; }; 546A6A2F22C585580034E806 /* SettingsAboutView.m in Sources */ = {isa = PBXBuildFile; fileRef = 546A6A2D22C585580034E806 /* SettingsAboutView.m */; }; @@ -24,6 +25,7 @@ 546FC44321189975007CC3A3 /* SettingsGeneral.m in Sources */ = {isa = PBXBuildFile; fileRef = 546FC44121189975007CC3A3 /* SettingsGeneral.m */; }; 5477D34E21233C62002BA27F /* FeedGroup+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 5477D34D21233C62002BA27F /* FeedGroup+Ext.m */; }; 5478DF04225A7AE200D30C64 /* SettingsFeedsView.m in Sources */ = {isa = PBXBuildFile; fileRef = 5478DF03225A7AE200D30C64 /* SettingsFeedsView.m */; }; + 548C6D0A230C33DE003A1AAF /* NSURL+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 548C6D09230C33DE003A1AAF /* NSURL+Ext.m */; }; 5496B511214D6275003ED4ED /* UserPrefs.m in Sources */ = {isa = PBXBuildFile; fileRef = 5496B510214D6275003ED4ED /* UserPrefs.m */; }; 54A07A7F220E04CF00082C51 /* NSFetchRequest+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54A07A7E220E04CF00082C51 /* NSFetchRequest+Ext.m */; }; 54A07A82220E723D00082C51 /* MapUnreadTotal.m in Sources */ = {isa = PBXBuildFile; fileRef = 54A07A81220E723D00082C51 /* MapUnreadTotal.m */; }; @@ -31,11 +33,12 @@ 54ACC28C21061B3C0020715F /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC28B21061B3C0020715F /* main.m */; }; 54ACC29521061E270020715F /* UpdateScheduler.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC29421061E270020715F /* UpdateScheduler.m */; }; 54ACC29821061FBA0020715F /* Preferences.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC29721061FBA0020715F /* Preferences.m */; }; - 54AD4E0023005297000AE386 /* WebFeed.m in Sources */ = {isa = PBXBuildFile; fileRef = 54AD4DFF23005297000AE386 /* WebFeed.m */; }; 54AD4E0C2301853D000AE386 /* NSString+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54AD4E0B2301853D000AE386 /* NSString+Ext.m */; }; 54AD4EE72305B17D000AE386 /* container-migration.plist in Resources */ = {isa = PBXBuildFile; fileRef = 54AD4EE62305B17D000AE386 /* container-migration.plist */; }; 54B51704226DC339006C1B29 /* ModalFeedEditView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B51703226DC339006C1B29 /* ModalFeedEditView.m */; }; 54B517072270E990006C1B29 /* NSView+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B517062270E92A006C1B29 /* NSView+Ext.m */; }; + 54B6F14A231551B3002C94C9 /* FaviconDownload.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B6F149231551B3002C94C9 /* FaviconDownload.m */; }; + 54B6F14E23155E1A002C94C9 /* NSURLRequest+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B6F14D23155E1A002C94C9 /* NSURLRequest+Ext.m */; }; 54B749DA2204A85C0022CC6D /* BarStatusItem.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B749D92204A85C0022CC6D /* BarStatusItem.m */; }; 54B749E0220636200022CC6D /* FeedArticle+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B749DF220635CD0022CC6D /* FeedArticle+Ext.m */; }; 54BB048921FD2AB500C303A5 /* NSDate+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54BB048821FD2AB500C303A5 /* NSDate+Ext.m */; }; @@ -43,6 +46,7 @@ 54D55D7322E624CD00057B98 /* SettingsFeeds+DragDrop.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D55D7222E624CD00057B98 /* SettingsFeeds+DragDrop.m */; }; 54D857CE227C5785001BA1C8 /* RefreshStatisticsView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D857CD227C5785001BA1C8 /* RefreshStatisticsView.m */; }; 54E3C02122EE076D006E2E24 /* opml-icon.icns in Resources */ = {isa = PBXBuildFile; fileRef = 54E3C02022EE076D006E2E24 /* opml-icon.icns */; }; + 54E4446C2329AE0600BBF481 /* NSError+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E4446B2329AE0600BBF481 /* NSError+Ext.m */; }; 54E88320211B509D00064188 /* ModalFeedEdit.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E8831E211B509D00064188 /* ModalFeedEdit.m */; }; 54E9CF32225914300023696F /* SettingsAbout.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E9CF31225914300023696F /* SettingsAbout.m */; }; 54F39C2E210BE1F700AEE730 /* DBv1.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC28221061B3B0020715F /* DBv1.xcdatamodeld */; }; @@ -114,6 +118,8 @@ 544B011C2114EE9100386E5C /* AppHook.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppHook.m; sourceTree = ""; }; 544DCCB8212A2B4D002DBC46 /* RSXML.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RSXML.framework; path = Carthage/Build/Mac/RSXML.framework; sourceTree = ""; }; 544DCCBD212A2B6F002DBC46 /* RSXML.framework.dSYM */ = {isa = PBXFileReference; lastKnownFileType = wrapper.dsym; name = RSXML.framework.dSYM; path = Carthage/Build/Mac/RSXML.framework.dSYM; sourceTree = ""; }; + 5450100E230E9C8600F0B165 /* FeedDownload.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FeedDownload.h; sourceTree = ""; }; + 5450100F230E9C8600F0B165 /* FeedDownload.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FeedDownload.m; sourceTree = ""; }; 546A6A2A22C584AF0034E806 /* SettingsAppearanceView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SettingsAppearanceView.m; sourceTree = ""; }; 546A6A2B22C584AF0034E806 /* SettingsAppearanceView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SettingsAppearanceView.h; sourceTree = ""; }; 546A6A2D22C585580034E806 /* SettingsAboutView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SettingsAboutView.m; sourceTree = ""; }; @@ -127,6 +133,8 @@ 5478DF02225A7AE200D30C64 /* SettingsFeedsView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SettingsFeedsView.h; sourceTree = ""; }; 5478DF03225A7AE200D30C64 /* SettingsFeedsView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SettingsFeedsView.m; sourceTree = ""; }; 54892F1D2235285700271CBA /* CHANGELOG.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; + 548C6D08230C33DE003A1AAF /* NSURL+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSURL+Ext.h"; sourceTree = ""; }; + 548C6D09230C33DE003A1AAF /* NSURL+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSURL+Ext.m"; sourceTree = ""; }; 5496B50F214D6275003ED4ED /* UserPrefs.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UserPrefs.h; sourceTree = ""; }; 5496B510214D6275003ED4ED /* UserPrefs.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UserPrefs.m; sourceTree = ""; }; 54A07A7D220E04CF00082C51 /* NSFetchRequest+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSFetchRequest+Ext.h"; sourceTree = ""; }; @@ -142,8 +150,6 @@ 54ACC29421061E270020715F /* UpdateScheduler.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = UpdateScheduler.m; sourceTree = ""; }; 54ACC29621061FBA0020715F /* Preferences.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Preferences.h; sourceTree = ""; }; 54ACC29721061FBA0020715F /* Preferences.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = Preferences.m; sourceTree = ""; }; - 54AD4DFE23005297000AE386 /* WebFeed.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WebFeed.h; sourceTree = ""; }; - 54AD4DFF23005297000AE386 /* WebFeed.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = WebFeed.m; sourceTree = ""; }; 54AD4E0A2301853D000AE386 /* NSString+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSString+Ext.h"; sourceTree = ""; }; 54AD4E0B2301853D000AE386 /* NSString+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSString+Ext.m"; sourceTree = ""; }; 54AD4EE42305AF60000AE386 /* baRSS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = baRSS.entitlements; sourceTree = ""; }; @@ -152,6 +158,10 @@ 54B51703226DC339006C1B29 /* ModalFeedEditView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ModalFeedEditView.m; sourceTree = ""; }; 54B517052270E8C6006C1B29 /* NSView+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSView+Ext.h"; sourceTree = ""; }; 54B517062270E92A006C1B29 /* NSView+Ext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSView+Ext.m"; sourceTree = ""; }; + 54B6F148231551B3002C94C9 /* FaviconDownload.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FaviconDownload.h; sourceTree = ""; }; + 54B6F149231551B3002C94C9 /* FaviconDownload.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FaviconDownload.m; sourceTree = ""; }; + 54B6F14C23155E1A002C94C9 /* NSURLRequest+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSURLRequest+Ext.h"; sourceTree = ""; }; + 54B6F14D23155E1A002C94C9 /* NSURLRequest+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSURLRequest+Ext.m"; sourceTree = ""; }; 54B749D82204A85C0022CC6D /* BarStatusItem.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BarStatusItem.h; sourceTree = ""; }; 54B749D92204A85C0022CC6D /* BarStatusItem.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BarStatusItem.m; sourceTree = ""; }; 54B749DE220635BE0022CC6D /* FeedArticle+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FeedArticle+Ext.h"; sourceTree = ""; }; @@ -166,6 +176,8 @@ 54D857D022802309001BA1C8 /* SettingsGeneralView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SettingsGeneralView.h; sourceTree = ""; }; 54D857D122802309001BA1C8 /* SettingsGeneralView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SettingsGeneralView.m; sourceTree = ""; }; 54E3C02022EE076D006E2E24 /* opml-icon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = "opml-icon.icns"; sourceTree = ""; }; + 54E4446A2329AE0600BBF481 /* NSError+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSError+Ext.h"; sourceTree = ""; }; + 54E4446B2329AE0600BBF481 /* NSError+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSError+Ext.m"; sourceTree = ""; }; 54E8831D211B509D00064188 /* ModalFeedEdit.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ModalFeedEdit.h; sourceTree = ""; }; 54E8831E211B509D00064188 /* ModalFeedEdit.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ModalFeedEdit.m; sourceTree = ""; }; 54E9CF30225914300023696F /* SettingsAbout.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SettingsAbout.h; sourceTree = ""; }; @@ -210,12 +222,18 @@ children = ( 54209E922117325100F3B5EF /* DrawImage.h */, 54209E932117325100F3B5EF /* DrawImage.m */, - 54BB048721FD2AB500C303A5 /* NSDate+Ext.h */, - 54BB048821FD2AB500C303A5 /* NSDate+Ext.m */, 54B517052270E8C6006C1B29 /* NSView+Ext.h */, 54B517062270E92A006C1B29 /* NSView+Ext.m */, 54AD4E0A2301853D000AE386 /* NSString+Ext.h */, 54AD4E0B2301853D000AE386 /* NSString+Ext.m */, + 54BB048721FD2AB500C303A5 /* NSDate+Ext.h */, + 54BB048821FD2AB500C303A5 /* NSDate+Ext.m */, + 54E4446A2329AE0600BBF481 /* NSError+Ext.h */, + 54E4446B2329AE0600BBF481 /* NSError+Ext.m */, + 548C6D08230C33DE003A1AAF /* NSURL+Ext.h */, + 548C6D09230C33DE003A1AAF /* NSURL+Ext.m */, + 54B6F14C23155E1A002C94C9 /* NSURLRequest+Ext.h */, + 54B6F14D23155E1A002C94C9 /* NSURLRequest+Ext.m */, ); path = Helper; sourceTree = ""; @@ -316,8 +334,10 @@ children = ( 54ACC29321061E270020715F /* UpdateScheduler.h */, 54ACC29421061E270020715F /* UpdateScheduler.m */, - 54AD4DFE23005297000AE386 /* WebFeed.h */, - 54AD4DFF23005297000AE386 /* WebFeed.m */, + 5450100E230E9C8600F0B165 /* FeedDownload.h */, + 5450100F230E9C8600F0B165 /* FeedDownload.m */, + 54B6F148231551B3002C94C9 /* FaviconDownload.h */, + 54B6F149231551B3002C94C9 /* FaviconDownload.m */, 54F6025B21C1D4170006D338 /* OpmlFile.h */, 54F6025C21C1D4170006D338 /* OpmlFile.m */, ); @@ -537,7 +557,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 54AD4E0023005297000AE386 /* WebFeed.m in Sources */, 54B51704226DC339006C1B29 /* ModalFeedEditView.m in Sources */, 54AD4E0C2301853D000AE386 /* NSString+Ext.m in Sources */, 546A6A2C22C584AF0034E806 /* SettingsAppearanceView.m in Sources */, @@ -551,9 +570,12 @@ 54ACC29521061E270020715F /* UpdateScheduler.m in Sources */, 54BB048921FD2AB500C303A5 /* NSDate+Ext.m in Sources */, 5477D34E21233C62002BA27F /* FeedGroup+Ext.m in Sources */, + 54B6F14A231551B3002C94C9 /* FaviconDownload.m in Sources */, + 54E4446C2329AE0600BBF481 /* NSError+Ext.m in Sources */, 5478DF04225A7AE200D30C64 /* SettingsFeedsView.m in Sources */, 540F704521B6C16C0022E69D /* FeedMeta+Ext.m in Sources */, 54ACC28C21061B3C0020715F /* main.m in Sources */, + 54B6F14E23155E1A002C94C9 /* NSURLRequest+Ext.m in Sources */, 54FE73D3212316CD003EAC65 /* BarMenu.m in Sources */, 544B011A2114B41200386E5C /* ModalSheet.m in Sources */, 54A07A82220E723D00082C51 /* MapUnreadTotal.m in Sources */, @@ -567,8 +589,10 @@ 541C67C32255470B004D2CE6 /* SettingsAppearance.m in Sources */, 54B749DA2204A85C0022CC6D /* BarStatusItem.m in Sources */, 546FC43D21188AD5007CC3A3 /* SettingsFeeds.m in Sources */, + 548C6D0A230C33DE003A1AAF /* NSURL+Ext.m in Sources */, 54E88320211B509D00064188 /* ModalFeedEdit.m in Sources */, 54195883218A061100581B79 /* Feed+Ext.m in Sources */, + 54501010230E9C8600F0B165 /* FeedDownload.m in Sources */, 54209E942117325100F3B5EF /* DrawImage.m in Sources */, 54FE73D021220DEC003EAC65 /* StoreCoordinator.m in Sources */, 54A07A7F220E04CF00082C51 /* NSFetchRequest+Ext.m in Sources */, diff --git a/baRSS/AppHook.m b/baRSS/AppHook.m index 320c755..1df7263 100644 --- a/baRSS/AppHook.m +++ b/baRSS/AppHook.m @@ -22,14 +22,14 @@ #import "AppHook.h" #import "Constants.h" -#import "BarStatusItem.h" -#import "WebFeed.h" -#import "UpdateScheduler.h" -#import "Preferences.h" #import "DrawImage.h" -#import "SettingsFeeds+DragDrop.h" #import "UserPrefs.h" +#import "Preferences.h" +#import "BarStatusItem.h" +#import "UpdateScheduler.h" #import "StoreCoordinator.h" +#import "SettingsFeeds+DragDrop.h" +#import "NSURL+Ext.h" @interface AppHook() @property (strong) NSWindowController *prefWindow; @@ -53,11 +53,15 @@ } - (void)applicationDidFinishLaunching:(NSNotification *)aNotification { + BOOL initial = [[NSURL faviconsCacheURL] mkdir]; [_statusItem asyncReloadUnreadCount]; [UpdateScheduler registerNetworkChangeNotification]; // will call update scheduler if ([StoreCoordinator isEmpty]) { [_statusItem showWelcomeMessage]; - [WebFeed autoDownloadAndParseUpdateURL]; + [UpdateScheduler autoDownloadAndParseUpdateURL]; + } else { + // mostly for version migration 0.9.4 ~> 1.0 (favicon storage) + if (initial) [UpdateScheduler updateAllFavicons]; } } @@ -109,8 +113,8 @@ @synthesize persistentContainer = _persistentContainer; +/// The persistent container for the application. This implementation creates and returns a container, having loaded the store for the application to it. - (NSPersistentContainer *)persistentContainer { - // The persistent container for the application. This implementation creates and returns a container, having loaded the store for the application to it. @synchronized (self) { if (_persistentContainer == nil) { _persistentContainer = [[NSPersistentContainer alloc] initWithName:@"DBv1"]; @@ -125,28 +129,23 @@ return _persistentContainer; } +/// Save changes in the application's managed object context before the application terminates. - (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sender { - // Save changes in the application's managed object context before the application terminates. NSManagedObjectContext *context = self.persistentContainer.viewContext; - if (![context commitEditing]) { NSLog(@"%@:%@ unable to commit editing to terminate", [self class], NSStringFromSelector(_cmd)); return NSTerminateCancel; } - if (!context.hasChanges) { return NSTerminateNow; } - NSError *error = nil; if (![context save:&error]) { - // Customize this code block to include application-specific recovery steps. BOOL result = [sender presentError:error]; if (result) { return NSTerminateCancel; } - NSString *question = NSLocalizedString(@"Could not save changes while quitting. Quit anyway?", @"Quit without saves error question message"); NSString *info = NSLocalizedString(@"Quitting now will lose any changes you have made since the last successful save", @"Quit without saves error question info"); NSString *quitButton = NSLocalizedString(@"Quit anyway", @"Quit anyway button title"); @@ -157,9 +156,7 @@ [alert addButtonWithTitle:quitButton]; [alert addButtonWithTitle:cancelButton]; - NSInteger answer = [alert runModal]; - - if (answer == NSAlertSecondButtonReturn) { + if ([alert runModal] == NSAlertSecondButtonReturn) { return NSTerminateCancel; } } @@ -170,9 +167,7 @@ #pragma mark - Application Input (URLs and Files) -/** - Callback method fired on opml file import - */ +/// Callback method fired on opml file import - (void)application:(NSApplication *)sender openFiles:(NSArray *)filenames { NSMutableArray *urls = [NSMutableArray arrayWithCapacity:filenames.count]; for (NSString *file in filenames) { @@ -184,9 +179,7 @@ [sender replyToOpenOrPrint:NSApplicationDelegateReplySuccess]; } -/** - Callback method fired when opened with an URL (@c feed: and @c barss: scheme) - */ +/// Callback method fired when opened with an URL (@c feed: and @c barss: scheme) - (void)handleAppleEvent:(NSAppleEventDescriptor *)event withReplyEvent:(NSAppleEventDescriptor *)replyEvent { NSString *url = [[event paramDescriptorForKeyword:keyDirectObject] stringValue]; NSString *scheme = [[[NSURL URLWithString:url] scheme] lowercaseString]; @@ -195,7 +188,7 @@ url = [url substringFromIndex:2]; } if ([scheme isEqualToString:kURLSchemeFeed]) { - [WebFeed autoDownloadAndParseURL:url addAnyway:NO modify:nil]; + [UpdateScheduler autoDownloadAndParseURL:url]; } else if ([scheme isEqualToString:kURLSchemeBarss]) { NSMutableArray *comp = [[url pathComponents] mutableCopy]; NSString *action = comp.firstObject; diff --git a/baRSS/Constants.h b/baRSS/Constants.h index 9b78f69..41c502c 100644 --- a/baRSS/Constants.h +++ b/baRSS/Constants.h @@ -61,8 +61,8 @@ static NSImageName const RSSImageMenuItemUnread = @"RSSImageMenuItemUnread"; /// Helper method calls @c (defaultCenter)postNotification: -NS_INLINE void PostNotification(NSNotificationName name, id obj) { [[NSNotificationCenter defaultCenter] postNotificationName:name object:obj]; } -NS_INLINE void RegisterNotification(NSNotificationName name, SEL action, id observer) { [[NSNotificationCenter defaultCenter] addObserver:observer selector:action name:name object:nil]; } +static inline void PostNotification(NSNotificationName name, id obj) { [[NSNotificationCenter defaultCenter] postNotificationName:name object:obj]; } +static inline void RegisterNotification(NSNotificationName name, SEL action, id observer) { [[NSNotificationCenter defaultCenter] addObserver:observer selector:action name:name object:nil]; } /** @c notification.object is @c NSNumber of type @c NSUInteger. Represents number of feeds that are proccessed in background update. Sends @c 0 when all downloads are finished. @@ -77,12 +77,12 @@ static NSNotificationName const kNotificationScheduleTimerChanged = @"baRSS-noti @c notification.object is @c NSManagedObjectID of type @c FeedGroup. Called whenever a new feed group was created in @c autoDownloadAndParseURL: */ -static NSNotificationName const kNotificationGroupInserted = @"baRSS-notification-group-inserted"; +static NSNotificationName const kNotificationFeedGroupInserted = @"baRSS-notification-feed-inserted"; /** @c notification.object is @c NSManagedObjectID of type @c Feed. - Called whenever download of a feed finished and object was modified (not if statusCode 304). + Called whenever download of a feed finished and articles were modified (not if statusCode 304). */ -static NSNotificationName const kNotificationFeedUpdated = @"baRSS-notification-feed-updated"; +static NSNotificationName const kNotificationArticlesUpdated = @"baRSS-notification-articles-updated"; /** @c notification.object is @c NSManagedObjectID of type @c Feed. Called whenever the icon attribute of an item was updated. diff --git a/baRSS/Core Data/Feed+Ext.h b/baRSS/Core Data/Feed+Ext.h index 4f2c618..1566fd0 100644 --- a/baRSS/Core Data/Feed+Ext.h +++ b/baRSS/Core Data/Feed+Ext.h @@ -25,16 +25,16 @@ @class RSParsedFeed; @interface Feed (Ext) +@property (readonly) BOOL hasIcon; @property (nonnull, readonly) NSImage* iconImage16; // Generator methods / Feed update + (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context; -+ (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc; - (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag; -- (void)calculateAndSetIndexPathString; - (NSMenuItem*)newMenuItem; +// Getter & Setter +- (void)calculateAndSetIndexPathString; +- (void)setNewIcon:(NSURL*)location; // Article properties - (NSArray*)sortedArticles; -// Icon -- (BOOL)setIconImage:(NSImage*)img; @end diff --git a/baRSS/Core Data/Feed+Ext.m b/baRSS/Core Data/Feed+Ext.m index c7b45ff..5ed060f 100644 --- a/baRSS/Core Data/Feed+Ext.m +++ b/baRSS/Core Data/Feed+Ext.m @@ -28,6 +28,7 @@ #import "FeedGroup+Ext.h" #import "FeedArticle+Ext.h" #import "StoreCoordinator.h" +#import "NSURL+Ext.h" @implementation Feed (Ext) @@ -38,14 +39,6 @@ return feed; } -/// Instantiates new @c FeedGroup with @c FEED type, set the update interval to @c 30min and @c sortIndex to last root index. -+ (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc { - NSUInteger lastIndex = [StoreCoordinator countRootItemsInContext:moc]; - FeedGroup *fg = [FeedGroup newGroup:FEED inContext:moc]; - [fg setParent:nil andSortIndex:(int32_t)lastIndex]; - return fg.feed; -} - /// Call @c indexPathString on @c .group and update @c .indexPath if current value is different. - (void)calculateAndSetIndexPathString { NSString *pthStr = [self.group indexPathString]; @@ -56,7 +49,7 @@ /// @return Fully initialized @c NSMenuItem with @c title, @c tooltip, @c image, and @c action. - (NSMenuItem*)newMenuItem { NSMenuItem *item = [NSMenuItem new]; - item.title = self.group.nameOrError; + item.title = self.group.anyName; item.toolTip = self.subtitle; item.enabled = (self.articles.count > 0); item.image = self.iconImage16; @@ -85,9 +78,6 @@ if (![self.subtitle isEqualToString:obj.subtitle]) self.subtitle = obj.subtitle; if (![self.link isEqualToString:obj.link]) self.link = obj.link; - if (self.group.name.length == 0) // in case a blank group was initialized - self.group.name = obj.title; - // Add and remove articles NSMutableSet *localSet = [self.articles mutableCopy]; NSInteger diff = 0; @@ -117,6 +107,7 @@ // reverse enumeration ensures correct article order FeedArticle *storedArticle = [self findRemoteArticle:article inLocalSet:localSet]; if (storedArticle) { + // TODO: stop bullshitting with ghost articles [localSet removeObject:storedArticle]; // If we encounter an already existing item, assume newly inserted are "ghost" items and mark read. if (newlyInserted.count > 0) { @@ -215,15 +206,13 @@ #pragma mark - Icon - -/** - @return Return @c 16x16px image. Either from core data storage or generated default RSS icon. - */ +/// @return @c 16x16px image. Either from favicon cache or generated default RSS icon. - (nonnull NSImage*)iconImage16 { NSImage *img = nil; if (self.articles.count == 0) { img = [NSImage imageNamed:NSImageNameCaution]; - } else if (self.icon.icon) { - img = [[NSImage alloc] initWithData:self.icon.icon]; + } else if (self.hasIcon) { + img = [[NSImage alloc] initByReferencingURL:[self iconPath]]; } else { img = [NSImage imageNamed:RSSImageDefaultRSSIcon]; } @@ -231,23 +220,26 @@ return img; } -/** - Set favicon icon or delete relationship if @c img is not a valid image. - - @return @c YES if icon was updated (core data did change). -*/ -- (BOOL)setIconImage:(NSImage*)img { - if (img && [img isValid]) { - if (!self.icon) - self.icon = [[FeedIcon alloc] initWithEntity:FeedIcon.entity insertIntoManagedObjectContext:self.managedObjectContext]; - self.icon.icon = [img TIFFRepresentation]; - return YES; - } else if (self.icon) { - [self.managedObjectContext deleteObject:self.icon]; - self.icon = nil; - return YES; +/// Checks if file at @c iconPath is an actual file +- (BOOL)hasIcon { return [[self iconPath] existsAndIsDir:NO]; } + +/// Image file path at e.g., "Application Support/baRSS/favicons/p42". @warning File may not exist! +- (NSURL*)iconPath { + NSString *pk = self.objectID.URIRepresentation.lastPathComponent; + return [[NSURL faviconsCacheURL] URLByAppendingPathComponent:pk isDirectory:NO]; +} + +/// Move favicon from @c $TMPDIR to permanent destination in Application Support. +- (void)setNewIcon:(NSURL*)location { + if (!location) { + [[self iconPath] remove]; + } else { + if (self.objectID.isTemporaryID) { + [self.managedObjectContext obtainPermanentIDsForObjects:@[self] error:nil]; + } + [location moveTo:[self iconPath]]; + PostNotification(kNotificationFeedIconUpdated, self.objectID); } - return NO; } @end diff --git a/baRSS/Core Data/FeedGroup+Ext.h b/baRSS/Core Data/FeedGroup+Ext.h index cea8e22..717de6d 100644 --- a/baRSS/Core Data/FeedGroup+Ext.h +++ b/baRSS/Core Data/FeedGroup+Ext.h @@ -33,14 +33,15 @@ typedef NS_ENUM(int16_t, FeedGroupType) { @interface FeedGroup (Ext) /// Overwrites @c type attribute with enum. Use one of: @c GROUP, @c FEED, @c SEPARATOR. @property (nonatomic) FeedGroupType type; -@property (nonnull, readonly) NSString *nameOrError; +@property (nonnull, readonly) NSString *anyName; @property (nonnull, readonly) NSImage* groupIconImage16; @property (nonnull, readonly) NSImage* iconImage16; + (instancetype)newGroup:(FeedGroupType)type inContext:(NSManagedObjectContext*)context; ++ (instancetype)appendToRoot:(FeedGroupType)type inContext:(NSManagedObjectContext*)moc; - (void)setParent:(FeedGroup *)parent andSortIndex:(int32_t)sortIndex; - (void)setSortIndexIfChanged:(int32_t)sortIndex; -- (void)setNameIfChanged:(NSString*)name; +- (void)setNameIfChanged:(nullable NSString*)name; - (NSMenuItem*)newMenuItem; // Handle children and parents - (NSString*)indexPathString; diff --git a/baRSS/Core Data/FeedGroup+Ext.m b/baRSS/Core Data/FeedGroup+Ext.m index dea4a57..f833806 100644 --- a/baRSS/Core Data/FeedGroup+Ext.m +++ b/baRSS/Core Data/FeedGroup+Ext.m @@ -21,17 +21,22 @@ // SOFTWARE. #import "FeedGroup+Ext.h" -#import "FeedMeta+Ext.h" #import "Feed+Ext.h" +#import "FeedMeta+Ext.h" +#import "StoreCoordinator.h" #import "NSDate+Ext.h" @implementation FeedGroup (Ext) #pragma mark - Properties -/// @return Returns "(no title)" if @c self.name is @c nil. -- (nonnull NSString*)nameOrError { - return (self.name ? self.name : NSLocalizedString(@"(no title)", nil)); +/// Try return @c self.name or @c self.feed.title ; If both fail return "(no title)" +- (nonnull NSString*)anyName { + if (self.name.length > 0) + return self.name; + if (self.type == FEED && self.feed.title.length > 0) + return self.feed.title; + return NSLocalizedString(@"(no title)", nil); } /// @return Return @c 16x16px NSImageNameFolder image. @@ -66,6 +71,14 @@ return fg; } +/// Instantiates new @c FeedGroup at root level and append at end. ++ (instancetype)appendToRoot:(FeedGroupType)type inContext:(NSManagedObjectContext*)moc { + NSUInteger lastIndex = [StoreCoordinator countRootItemsInContext:moc]; + FeedGroup *fg = [FeedGroup newGroup:type inContext:moc]; + [fg setParent:nil andSortIndex:(int32_t)lastIndex]; + return fg; +} + /// Set @c parent and @c sortIndex. Also if type is @c FEED calculate and set @c indexPath string. - (void)setParent:(FeedGroup *)parent andSortIndex:(int32_t)sortIndex { self.parent = parent; @@ -85,15 +98,19 @@ } /// Set @c name attribute but only if value differs. -- (void)setNameIfChanged:(NSString*)name { - if (![self.name isEqualToString: name]) +- (void)setNameIfChanged:(nullable NSString*)name { + if (name.length == 0) { + if (self.name.length > 0) + self.name = nil; // nullify empty strings + } else if (![self.name isEqualToString: name]) { self.name = name; + } } /// @return Fully initialized @c NSMenuItem with @c title and @c image. - (NSMenuItem*)newMenuItem { NSMenuItem *item = [NSMenuItem new]; - item.title = self.nameOrError; + item.title = self.anyName; item.enabled = (self.children.count > 0); item.image = self.groupIconImage16; item.representedObject = self.objectID; @@ -155,8 +172,8 @@ /// @return Simplified description of the feed object. - (NSString*)readableDescription { switch (self.type) { - case GROUP: return [NSString stringWithFormat:@"%@:", self.name]; - case FEED: return [NSString stringWithFormat:@"%@ (%@)", self.name, self.feed.meta.url]; + case GROUP: return [NSString stringWithFormat:@"%@:", self.anyName]; + case FEED: return [NSString stringWithFormat:@"%@ (%@)", self.anyName, self.feed.meta.url]; case SEPARATOR: return @"-------------"; } } diff --git a/baRSS/Core Data/FeedMeta+Ext.h b/baRSS/Core Data/FeedMeta+Ext.h index 2a297f8..1c49994 100644 --- a/baRSS/Core Data/FeedMeta+Ext.h +++ b/baRSS/Core Data/FeedMeta+Ext.h @@ -32,6 +32,6 @@ static int32_t const kDefaultFeedRefreshInterval = 30 * 60; - (void)setSucessfulWithResponse:(NSHTTPURLResponse*)response; // Setter - (void)setUrlIfChanged:(NSString*)url; -- (BOOL)setRefreshAndSchedule:(int32_t)refresh; +- (void)setRefreshIfChanged:(int32_t)refresh; - (void)scheduleNow:(NSTimeInterval)future; @end diff --git a/baRSS/Core Data/FeedMeta+Ext.m b/baRSS/Core Data/FeedMeta+Ext.m index 3c1c784..5bf24b2 100644 --- a/baRSS/Core Data/FeedMeta+Ext.m +++ b/baRSS/Core Data/FeedMeta+Ext.m @@ -51,6 +51,7 @@ [self scheduleNow:retryWaitTime]; } +/// Copy Etag & Last-Modified headers and update URL (if not 304). Then schedule new update date. Will reset errorCount to @c 0 - (void)setSucessfulWithResponse:(NSHTTPURLResponse*)response { self.errorCount = 0; // reset counter NSDictionary *header = [response allHeaderFields]; @@ -68,26 +69,17 @@ if (![self.url isEqualToString:url]) self.url = url; } +/// Set @c refresh attribute but only if value differs. +- (void)setRefreshIfChanged:(int32_t)refresh { + if (self.refresh != refresh) self.refresh = refresh; +} + /// Set @c etag and @c modified attributes. Only values that differ will be updated. - (void)setEtag:(NSString*)etag modified:(NSString*)modified { if (![self.etag isEqualToString:etag]) self.etag = etag; if (![self.modified isEqualToString:modified]) self.modified = modified; } -/** - Set @c refresh and calculate new @c scheduled date. - - @return @c YES if refresh interval has changed - */ -- (BOOL)setRefreshAndSchedule:(int32_t)refresh { - if (self.refresh != refresh) { - self.refresh = refresh; - [self scheduleNow:self.refresh]; - return YES; - } - return NO; -} - /// Set next scheduled feed update or @c nil if @c refresh @c <= @c 0. - (void)scheduleNow:(NSTimeInterval)future { if (self.refresh <= 0) { // update deactivated; manually update with force update all diff --git a/baRSS/Core Data/StoreCoordinator.h b/baRSS/Core Data/StoreCoordinator.h index 18e8a81..cdbf45b 100644 --- a/baRSS/Core Data/StoreCoordinator.h +++ b/baRSS/Core Data/StoreCoordinator.h @@ -36,7 +36,7 @@ static int const dbFileVersion = 1; // update in case database structure changes // Feed update + (NSDate*)nextScheduledUpdate; -+ (NSArray*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc; ++ (NSArray*)listOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc; // Count elements + (BOOL)isEmpty; @@ -48,7 +48,6 @@ static int const dbFileVersion = 1; // update in case database structure changes + (NSArray*)sortedFeedGroupsWithParent:(id)parent inContext:(NSManagedObjectContext*)moc; + (NSArray*)sortedArticlesWithParent:(id)parent inContext:(NSManagedObjectContext*)moc; + (NSArray*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc; -+ (NSArray*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc; + (Feed*)feedWithIndexPath:(nonnull NSString*)path inContext:(NSManagedObjectContext*)moc; + (NSString*)urlForFeedWithIndexPath:(nonnull NSString*)path; @@ -57,4 +56,5 @@ static int const dbFileVersion = 1; // update in case database structure changes // Restore sound state + (void)cleanupAndShowAlert:(BOOL)flag; ++ (NSUInteger)cleanupFavicons; @end diff --git a/baRSS/Core Data/StoreCoordinator.m b/baRSS/Core Data/StoreCoordinator.m index 7452735..795782b 100644 --- a/baRSS/Core Data/StoreCoordinator.m +++ b/baRSS/Core Data/StoreCoordinator.m @@ -21,10 +21,12 @@ // SOFTWARE. #import "StoreCoordinator.h" -#import "Constants.h" -#import "NSFetchRequest+Ext.h" #import "AppHook.h" +#import "Constants.h" +#import "FaviconDownload.h" #import "Feed+Ext.h" +#import "NSURL+Ext.h" +#import "NSFetchRequest+Ext.h" @implementation StoreCoordinator @@ -57,7 +59,7 @@ NSError *error = nil; if (context.hasChanges && ![context save:&error]) { // Customize this code block to include application-specific recovery steps. - [[NSApplication sharedApplication] presentError:error]; + [NSApp presentError:error]; } if (flag && context.parentContext) { [self saveContext:context.parentContext andParent:flag]; @@ -101,11 +103,11 @@ @param forceAll If @c YES get a list of all @c Feed regardless of schedules time. */ -+ (NSArray*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc { ++ (NSArray*)listOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc { NSFetchRequest *fr = [Feed fetchRequest]; if (!forceAll) { - // when fetching also get those feeds that would need update soon (now + 10s) - [fr where:@"meta.scheduled <= %@", [NSDate dateWithTimeIntervalSinceNow:+10]]; + // when fetching also get those feeds that would need update soon (now + 2s) + [fr where:@"meta.scheduled <= %@", [NSDate dateWithTimeIntervalSinceNow:+2]]; } return [fr fetchAllRows:moc]; } @@ -156,11 +158,6 @@ return [[[Feed fetchRequest] where:@"articles.@count == 0"] fetchAllRows:moc]; } -/// @return Unsorted list of @c Feed items where @c icon is @c nil. -+ (NSArray*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc { - return [[[Feed fetchRequest] where:@"icon = NULL"] fetchAllRows:moc]; -} - /// @return Single @c Feed item where @c Feed.indexPath @c = @c path. + (Feed*)feedWithIndexPath:(nonnull NSString*)path inContext:(NSManagedObjectContext*)moc { return [[[Feed fetchRequest] where:@"indexPath = %@", path] fetchFirst:moc]; @@ -225,6 +222,7 @@ #pragma mark - Restore Sound State +/// Remove orphan core data entries with optional alert message of removed items count. + (void)cleanupAndShowAlert:(BOOL)flag { NSUInteger deleted = [self deleteUnreferenced]; [self restoreFeedIndexPaths]; @@ -256,7 +254,6 @@ NSManagedObjectContext *moc = [self getMainContext]; deleted += [self batchDelete:Feed.entity nullAttribute:@"group" inContext:moc]; deleted += [self batchDelete:FeedMeta.entity nullAttribute:@"feed" inContext:moc]; - deleted += [self batchDelete:FeedIcon.entity nullAttribute:@"feed" inContext:moc]; deleted += [self batchDelete:FeedArticle.entity nullAttribute:@"feed" inContext:moc]; if (deleted > 0) { [self saveContext:moc andParent:YES]; @@ -291,4 +288,25 @@ return [res.result unsignedIntegerValue]; } +/// Remove orphan favicons. @return Number of removed items. ++ (NSUInteger)cleanupFavicons { + NSURL *base = [[NSURL faviconsCacheURL] URLByResolvingSymlinksInPath]; + if (![base existsAndIsDir:YES]) return 0; + + NSFileManager *fm = [NSFileManager defaultManager]; + NSDirectoryEnumerationOptions opt = NSDirectoryEnumerationSkipsSubdirectoryDescendants | NSDirectoryEnumerationSkipsPackageDescendants | NSDirectoryEnumerationSkipsHiddenFiles; + NSDirectoryEnumerator *enumerator = [fm enumeratorAtURL:base includingPropertiesForKeys:nil options:opt errorHandler:nil]; + NSMutableArray *toBeDeleted = [NSMutableArray array]; + + NSArray *feedIds = [[Feed fetchRequest] fetchIDs:[self getMainContext]]; + NSArray *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 diff --git a/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents b/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents index 578f79a..a557ea4 100644 --- a/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents +++ b/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents @@ -7,7 +7,6 @@ - @@ -30,10 +29,6 @@ - - - - @@ -48,10 +43,9 @@ - + - diff --git a/baRSS/Feed Import/FaviconDownload.h b/baRSS/Feed Import/FaviconDownload.h new file mode 100644 index 0000000..1d1075d --- /dev/null +++ b/baRSS/Feed Import/FaviconDownload.h @@ -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)observer; +- (instancetype)startWithBlock:(nonnull FaviconDownloadBlock)block; +- (void)cancel; +// Extract from HTML metadata ++ (nullable NSString*)urlForMetadata:(RSHTMLMetadata*)meta; +@end + + +@protocol FaviconDownloadDelegate +@required +/// Called after image download. Called on error, but not if download is cancled. +- (void)faviconDownload:(FaviconDownload*)sender didFinish:(nullable NSURL*)path; +@end diff --git a/baRSS/Feed Import/FaviconDownload.m b/baRSS/Feed Import/FaviconDownload.m new file mode 100644 index 0000000..0c49df4 --- /dev/null +++ b/baRSS/Feed Import/FaviconDownload.m @@ -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 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)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 + 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 diff --git a/baRSS/Feed Import/FeedDownload.h b/baRSS/Feed Import/FeedDownload.h new file mode 100644 index 0000000..0b38a76 --- /dev/null +++ b/baRSS/Feed Import/FeedDownload.h @@ -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)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 +@optional +/// Delegate must return chosen URL. If not implemented, the first URL will be used. +- (NSString*)feedDownload:(FeedDownload*)sender selectFeedFromList:(NSArray*)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 diff --git a/baRSS/Feed Import/FeedDownload.m b/baRSS/Feed Import/FeedDownload.m new file mode 100644 index 0000000..ae1f687 --- /dev/null +++ b/baRSS/Feed Import/FeedDownload.m @@ -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 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)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)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 diff --git a/baRSS/Feed Import/OpmlFile.m b/baRSS/Feed Import/OpmlFile.m index 99747e9..b933a22 100644 --- a/baRSS/Feed Import/OpmlFile.m +++ b/baRSS/Feed Import/OpmlFile.m @@ -32,7 +32,7 @@ #pragma mark - Helper /// Loop over all subviews and find the @c NSButton that is selected. -NS_INLINE NSInteger RadioGroupSelection(NSView *view) { +static NSInteger RadioGroupSelection(NSView *view) { for (NSButton *btn in view.subviews) { if ([btn isKindOfClass:[NSButton class]] && btn.state == NSControlStateValueOn) { return btn.tag; @@ -93,8 +93,7 @@ NS_INLINE NSInteger RadioGroupSelection(NSView *view) { - (void)enumerateFiles:(NSArray*)files withBlock:(void(^)(RSOPMLItem *item))block finally:(nullable dispatch_block_t)finally { dispatch_group_t group = dispatch_group_create(); for (NSURL *url in files) { - if (finally) dispatch_group_enter(group); - + dispatch_group_enter(group); NSData *data = [NSData dataWithContentsOfURL:url]; RSXMLData *xml = [[RSXMLData alloc] initWithData:data url:url]; RSOPMLParser *parser = [RSOPMLParser parserWithXMLData:xml]; @@ -106,7 +105,7 @@ NS_INLINE NSInteger RadioGroupSelection(NSView *view) { block(itm); } } - if (finally) dispatch_group_leave(group); + dispatch_group_leave(group); }]; } if (finally) dispatch_group_notify(group, dispatch_get_main_queue(), finally); @@ -251,9 +250,7 @@ NS_INLINE NSInteger RadioGroupSelection(NSView *view) { NSData *xml = [doc XMLDataWithOptions:NSXMLNodePreserveAttributeOrder | NSXMLNodePrettyPrint]; [xml writeToURL:url options:NSDataWritingAtomic error:&error]; } - if (error) { - [NSApp presentError:error]; - } + if (error) [NSApp presentError:error]; return error; } @@ -293,8 +290,8 @@ NS_INLINE NSInteger RadioGroupSelection(NSView *view) { // dont add group node if hierarchical == NO NSXMLElement *outline = [NSXMLElement elementWithName:@"outline"]; [parent addChild:outline]; - [outline addAttribute:[NSXMLNode attributeWithName:OPMLTitleKey stringValue:item.name]]; - [outline addAttribute:[NSXMLNode attributeWithName:OPMLTextKey stringValue:item.name]]; + [outline addAttribute:[NSXMLNode attributeWithName:OPMLTitleKey stringValue:item.anyName]]; + [outline addAttribute:[NSXMLNode attributeWithName:OPMLTextKey stringValue:item.anyName]]; if (item.type == SEPARATOR) { [outline addAttribute:[NSXMLNode attributeWithName:@"separator" stringValue:@"true"]]; // baRSS specific diff --git a/baRSS/Feed Import/UpdateScheduler.h b/baRSS/Feed Import/UpdateScheduler.h index 2c62685..24e4ba5 100644 --- a/baRSS/Feed Import/UpdateScheduler.h +++ b/baRSS/Feed Import/UpdateScheduler.h @@ -37,7 +37,11 @@ // Scheduling + (void)scheduleNextFeed; + (void)forceUpdateAllFeeds; -+ (void)downloadList:(NSArray*)list background:(BOOL)flag finally:(nullable os_block_t)block; ++ (void)downloadList:(NSArray*)list userInitiated:(BOOL)flag finally:(nullable os_block_t)block; ++ (void)updateAllFavicons; +// Auto Download & Parse Feed URL ++ (void)autoDownloadAndParseURL:(NSString*)url; ++ (void)autoDownloadAndParseUpdateURL; // Register for network change notifications + (void)registerNetworkChangeNotification; + (void)unregisterNetworkChangeNotification; diff --git a/baRSS/Feed Import/UpdateScheduler.m b/baRSS/Feed Import/UpdateScheduler.m index 4e5a999..a0098b3 100644 --- a/baRSS/Feed Import/UpdateScheduler.m +++ b/baRSS/Feed Import/UpdateScheduler.m @@ -22,24 +22,33 @@ @import SystemConfiguration; #import "UpdateScheduler.h" -#import "WebFeed.h" #import "Constants.h" #import "StoreCoordinator.h" #import "NSDate+Ext.h" +#import "FeedDownload.h" +#import "FaviconDownload.h" +#import "Feed+Ext.h" +#import "FeedMeta+Ext.h" +#import "FeedGroup+Ext.h" + +#include + static NSTimer *_timer; static SCNetworkReachabilityRef _reachability = NULL; static BOOL _isReachable = YES; static BOOL _updatePaused = NO; static BOOL _nextUpdateIsForced = NO; - +static _Atomic(NSUInteger) _queueSize = 0; @implementation UpdateScheduler -#pragma mark - User Interaction +// ################################################################ +// # MARK: - Getter & Setter - +// ################################################################ /// @return Number of feeds being currently downloaded. -+ (NSUInteger)feedsInQueue { return [WebFeed feedsInQueue]; } ++ (NSUInteger)feedsInQueue { return _queueSize; } /// @return Date when background update will fire. If updates are paused, date is @c distantFuture. + (NSDate *)dateScheduled { return _timer.fireDate; } @@ -48,7 +57,7 @@ static BOOL _nextUpdateIsForced = NO; + (BOOL)allowNetworkConnection { return (_isReachable && !_updatePaused); } /// @return @c YES if batch update is running -+ (BOOL)isUpdating { return [WebFeed feedsInQueue] > 0; } ++ (BOOL)isUpdating { return _queueSize > 0; } /// @return @c YES if update is paused by user. + (BOOL)isPaused { return _updatePaused; } @@ -78,7 +87,7 @@ static BOOL _nextUpdateIsForced = NO; /// Update status. 'Updating X feeds …' or empty string if not updating. + (NSString*)updatingXFeeds { - NSUInteger c = [WebFeed feedsInQueue]; + NSUInteger c = _queueSize; switch (c) { case 0: return @""; case 1: return NSLocalizedString(@"Updating 1 feed …", nil); @@ -86,17 +95,15 @@ static BOOL _nextUpdateIsForced = NO; } } +// ################################################################ +// # MARK: - Schedule Timer Actions - +// ################################################################ -#pragma mark - Update Feed Timer - - -/** - Get date of next up feed and start the timer. - */ +/// Get date of next up feed and start the timer. + (void)scheduleNextFeed { if (![self allowNetworkConnection]) // timer will restart once connection exists return; - if ([WebFeed feedsInQueue] > 0) // assume every update ends with scheduleNextFeed + if (_queueSize > 0) // assume every update ends with scheduleNextFeed return; // skip until called again NSDate *nextTime = [StoreCoordinator nextScheduledUpdate]; // if nextTime = nil, then no feeds to update if (nextTime && [nextTime timeIntervalSinceNow] < 1) { // mostly, if app was closed for a long time @@ -105,9 +112,7 @@ static BOOL _nextUpdateIsForced = NO; [self scheduleTimer:nextTime]; } -/** - Start download of all feeds (immediatelly) regardless of @c .scheduled property. - */ +/// Start download of all feeds (immediatelly) regardless of @c .scheduled property. + (void)forceUpdateAllFeeds { if (![self allowNetworkConnection]) // timer will restart once connection exists return; @@ -118,7 +123,7 @@ static BOOL _nextUpdateIsForced = NO; /** Set new @c .fireDate and @c .tolerance for update timer. - @param nextTime If @c nil timer will be disabled with a @c .fireDate very far in the future. + @param nextTime If @c nil disable timer and set @c .fireDate to distant future. */ + (void)scheduleTimer:(NSDate*)nextTime { static dispatch_once_t onceToken; @@ -134,9 +139,7 @@ static BOOL _nextUpdateIsForced = NO; PostNotification(kNotificationScheduleTimerChanged, nil); } -/** - Called when schedule timer runs out (earliest @c .schedule date). Or if forced by user request. - */ +/// Called when schedule timer runs out (earliest @c .schedule date). Or if forced by user. + (void)updateTimerCallback { #ifdef DEBUG NSLog(@"fired"); @@ -145,35 +148,115 @@ static BOOL _nextUpdateIsForced = NO; _nextUpdateIsForced = NO; NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; - NSArray *list = [StoreCoordinator getListOfFeedsThatNeedUpdate:updateAll inContext:moc]; + NSArray *list = [StoreCoordinator listOfFeedsThatNeedUpdate:updateAll inContext:moc]; //NSAssert(list.count > 0, @"ERROR: Something went wrong, timer fired too early."); - [self downloadList:list background:!updateAll finally:^{ + [self downloadList:list userInitiated:updateAll finally:^{ [StoreCoordinator saveContext:moc andParent:YES]; // save parents too ... [moc reset]; [self scheduleNextFeed]; // always reset the timer }]; } -/// Download list of feeds. Either silently in background or in foreground with alerts. -+ (void)downloadList:(NSArray*)list background:(BOOL)flag finally:(nullable os_block_t)block { - if (![self allowNetworkConnection]) { - if (block) block(); - } else if (flag) { - [WebFeed batchDownloadFeeds:list showErrorAlert:NO finally:block]; - } else { - // TODO: add undo grouping? - [WebFeed setRequestsAreUrgent:YES]; - [WebFeed batchDownloadFeeds:list showErrorAlert:YES finally:^{ - [WebFeed setRequestsAreUrgent:NO]; - if (block) block(); - }]; - } +// ################################################################ +// # 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*)list userInitiated:(BOOL)flag finally:(nullable os_block_t)block { + if (![self allowNetworkConnection]) { + if (block) block(); + return; + } + // Else: batch download + atomic_fetch_add_explicit(&_queueSize, list.count, memory_order_relaxed); + PostNotification(kNotificationBackgroundUpdateInProgress, @(_queueSize)); + dispatch_group_t group = dispatch_group_create(); + for (Feed *f in list) { + dispatch_group_enter(group); + [self updateFeed:f alert:flag isForced:flag finally:^{ + atomic_fetch_sub_explicit(&_queueSize, 1, memory_order_relaxed); + PostNotification(kNotificationBackgroundUpdateInProgress, @(_queueSize)); + dispatch_group_leave(group); + }]; + } + if (block) dispatch_group_notify(group, dispatch_get_main_queue(), block); +} -#pragma mark - Network Connection & Reachability +/// 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]; +} +/** + Start download request with existing @c Feed object. Reuses etag and modified headers (unless articles count is 0). + @note Will post a @c kNotificationArticlesUpdated notification if download was successful and status code is @b not 304. + */ ++ (void)updateFeed:(Feed*)feed alert:(BOOL)alert isForced:(BOOL)forced finally:(nullable os_block_t)block { + NSManagedObjectContext *moc = feed.managedObjectContext; + NSManagedObjectID *oid = feed.objectID; + [[FeedDownload withFeed:feed forced:forced] startWithBlock:^(FeedDownload *mem) { + if (alert && mem.error) // but still copy values for error count increment + AlertDownloadError(mem.error, mem.request.URL.absoluteString); + Feed *f = [moc objectWithID:oid]; + BOOL recentlyAdded = (f.articles.count == 0); // before copy values + BOOL downloadIcon = (!f.hasIcon && (recentlyAdded || forced)); + BOOL needsNotification = [mem copyValuesTo:f ignoreError:NO]; + [StoreCoordinator saveContext:moc andParent:YES]; + if (needsNotification) + PostNotification(kNotificationArticlesUpdated, oid); + if (downloadIcon && !mem.error) { + [FaviconDownload updateFeed:f finally:block]; + } else if (block) block(); // always call block(); with or without favicon download + }]; +} + +/** + Download feed at url and append to persistent store in root folder. On error present user modal alert. + Creates new @c FeedGroup, @c Feed, @c FeedMeta and @c FeedArticle instances and saves them to the persistent store. + */ ++ (void)autoDownloadAndParseURL:(NSString*)url addAnyway:(BOOL)flag name:(nullable NSString*)title refresh:(int32_t)interval { + [[FeedDownload withURL:url] startWithBlock:^(FeedDownload *mem) { + if (!flag && mem.error) { + AlertDownloadError(mem.error, url); + return; + } + NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; + FeedGroup *fg = [FeedGroup appendToRoot:FEED inContext:moc]; + [fg setNameIfChanged:title]; + [fg.feed.meta setRefreshIfChanged:interval]; + [mem copyValuesTo:fg.feed ignoreError:YES]; + [StoreCoordinator saveContext:moc andParent:YES]; + PostNotification(kNotificationFeedGroupInserted, fg.objectID); + if (!mem.error) [FaviconDownload updateFeed:fg.feed finally:nil]; + [moc reset]; + [UpdateScheduler scheduleNextFeed]; + }]; +} + +/// Download and process feed url. Auto update feed title with an update interval of 30 min. ++ (void)autoDownloadAndParseURL:(NSString*)url { + [self autoDownloadAndParseURL:url addAnyway:NO name:nil refresh:kDefaultFeedRefreshInterval]; +} + +/// Insert Github URL for version releases with update interval 2 days and rename @c FeedGroup item. ++ (void)autoDownloadAndParseUpdateURL { + [self autoDownloadAndParseURL:versionUpdateURL addAnyway:YES name:NSLocalizedString(@"baRSS releases", nil) refresh:2 * TimeUnitDays]; +} + +// ################################################################ +// # MARK: - Network Connection & Reachability - +// ################################################################ /// Set callback on @c self to listen for network reachability changes. + (void)registerNetworkChangeNotification { diff --git a/baRSS/Feed Import/WebFeed.h b/baRSS/Feed Import/WebFeed.h deleted file mode 100644 index 0930f91..0000000 --- a/baRSS/Feed Import/WebFeed.h +++ /dev/null @@ -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*)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 - - TIC TCP Conn Failed [21:0x1d417fb00]: 1:65 Err(65) - ==> EHOSTUNREACH in #import - */ diff --git a/baRSS/Feed Import/WebFeed.m b/baRSS/Feed Import/WebFeed.m deleted file mode 100644 index e21e138..0000000 --- a/baRSS/Feed Import/WebFeed.m +++ /dev/null @@ -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 - -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*)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 - 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 diff --git a/baRSS/Helper/NSError+Ext.h b/baRSS/Helper/NSError+Ext.h new file mode 100644 index 0000000..2b0f5ac --- /dev/null +++ b/baRSS/Helper/NSError+Ext.h @@ -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 diff --git a/baRSS/Helper/NSError+Ext.m b/baRSS/Helper/NSError+Ext.m new file mode 100644 index 0000000..549fe58 --- /dev/null +++ b/baRSS/Helper/NSError+Ext.m @@ -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 diff --git a/baRSS/Helper/NSURL+Ext.h b/baRSS/Helper/NSURL+Ext.h new file mode 100644 index 0000000..0f19451 --- /dev/null +++ b/baRSS/Helper/NSURL+Ext.h @@ -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 diff --git a/baRSS/Helper/NSURL+Ext.m b/baRSS/Helper/NSURL+Ext.m new file mode 100644 index 0000000..243756a --- /dev/null +++ b/baRSS/Helper/NSURL+Ext.m @@ -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 diff --git a/baRSS/Helper/NSURLRequest+Ext.h b/baRSS/Helper/NSURLRequest+Ext.h new file mode 100644 index 0000000..b5509a3 --- /dev/null +++ b/baRSS/Helper/NSURLRequest+Ext.h @@ -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 diff --git a/baRSS/Helper/NSURLRequest+Ext.m b/baRSS/Helper/NSURLRequest+Ext.m new file mode 100644 index 0000000..6e4dd6a --- /dev/null +++ b/baRSS/Helper/NSURLRequest+Ext.m @@ -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 + + 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 + */ + +@end diff --git a/baRSS/Info.plist b/baRSS/Info.plist index 7fb16e0..9bf3947 100644 --- a/baRSS/Info.plist +++ b/baRSS/Info.plist @@ -70,7 +70,7 @@ CFBundleVersion - 11519 + 13197 LSApplicationCategoryType public.app-category.news LSMinimumSystemVersion diff --git a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m index af8bb26..81e8883 100644 --- a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m +++ b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m @@ -20,20 +20,25 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +@import RSXML; #import "ModalFeedEdit.h" -#import "WebFeed.h" -#import "StoreCoordinator.h" +#import "ModalFeedEditView.h" +#import "RefreshStatisticsView.h" +#import "Constants.h" +#import "FeedDownload.h" +#import "FaviconDownload.h" #import "Feed+Ext.h" #import "FeedMeta+Ext.h" #import "FeedGroup+Ext.h" -#import "ModalFeedEditView.h" -#import "RefreshStatisticsView.h" -#import "NSDate+Ext.h" #import "NSView+Ext.h" +#import "NSDate+Ext.h" +#import "NSURL+Ext.h" - -#pragma mark - ModalEditDialog - - +// ################################################################ +// # +// # MARK: - ModalEditDialog - +// # +// ################################################################ @interface ModalEditDialog() @property (strong) FeedGroup *feedGroup; @@ -62,21 +67,20 @@ } @end +// ################################################################ +// # +// # MARK: - ModalFeedEdit - +// # +// ################################################################ -#pragma mark - ModalFeedEdit - - - -@interface ModalFeedEdit() +@interface ModalFeedEdit() @property (strong) IBOutlet ModalFeedEditView *view; // override -@property (strong) RefreshStatisticsView *statisticsView; - @property (copy) NSString *previousURL; // check if changed and avoid multiple download -@property (copy) NSString *faviconURL; -@property (strong) NSError *feedError; // download error or xml parser error -@property (strong) RSParsedFeed *feedResult; // parsed result -@property (strong) NSHTTPURLResponse *httpResponse; -@property (assign) BOOL didDownloadFeed; // check if feed articles need update +@property (strong) NSURL *faviconFile; +@property (strong) FeedDownload *memFeed; +@property (weak) FaviconDownload *memIcon; +@property (strong) RefreshStatisticsView *statisticsView; @end @implementation ModalFeedEdit @@ -91,12 +95,11 @@ [self populateTextFields:self.feedGroup]; } -/** - Pre-fill UI control field values with @c FeedGroup properties. - */ +/// Pre-fill UI control field values with @c FeedGroup properties. - (void)populateTextFields:(FeedGroup*)fg { if (!fg || [fg hasChanges]) return; // hasChanges is true only if newly created - self.view.name.objectValue = fg.name; + self.view.name.objectValue = fg.name; // user given feed title + self.view.name.placeholderString = fg.feed.title; // actual feed title self.view.url.objectValue = fg.feed.meta.url; self.previousURL = self.view.url.stringValue; self.view.favicon.image = [fg.feed iconImage16]; @@ -104,6 +107,10 @@ [self statsForCoreDataObject]; } +- (void)dealloc { + [self.faviconFile remove]; // Delete temporary favicon (if still exists) +} + #pragma mark - Edit Feed Data /** @@ -111,63 +118,42 @@ Set @c scheduled to a new date if refresh interval was changed. */ - (void)applyChangesToCoreDataObject { - Feed *feed = self.feedGroup.feed; + Feed *f = self.feedGroup.feed; + Interval intv = [NSDate intervalForPopup:self.view.refreshUnit andField:self.view.refreshNum]; [self.feedGroup setNameIfChanged:self.view.name.stringValue]; - FeedMeta *meta = feed.meta; - [meta setUrlIfChanged:self.previousURL]; - [meta setRefreshAndSchedule:[NSDate intervalForPopup:self.view.refreshUnit andField:self.view.refreshNum]]; - // updateTimer will be scheduled once preferences is closed - if (self.didDownloadFeed) { - [meta setSucessfulWithResponse:self.httpResponse]; - [feed updateWithRSS:self.feedResult postUnreadCountChange:YES]; - [feed setIconImage:self.view.favicon.image]; + [f.meta setRefreshIfChanged:intv]; + if (self.memFeed) { + [self.memFeed copyValuesTo:f ignoreError:YES]; + [f setNewIcon:self.faviconFile]; // only if downloaded anything (nil deletes icon!) + self.faviconFile = nil; } } +/// Cancel any running download task and free volatile variables +- (void)cancelDownloads { + [self.memFeed cancel]; self.memFeed = nil; + [self.memIcon cancel]; self.memIcon = nil; + [self.faviconFile remove]; self.faviconFile = nil; +} + /** - Prepare UI (nullify @c result, @c error and start @c ProgressIndicator). - Also disable 'Done' button during download and re-enable after all downloads are finished. + Prepare UI (nullify results and start @c ProgressIndicator ). + Also disable 'Done' button during download and re-enable after download is finished. */ -- (void)preDownload { +- (void)downloadRSS { + [self cancelDownloads]; [self.modalSheet setDoneEnabled:NO]; // prevent user from closing the dialog during download [self.view.spinnerURL startAnimation:nil]; [self.view.spinnerName startAnimation:nil]; self.view.favicon.image = nil; self.view.warningButton.hidden = YES; - self.didDownloadFeed = NO; - // Assuming the user has not changed title since the last fetch. - // Reset to "" because after download it will be pre-filled with new feed title - if ([self.view.name.stringValue isEqualToString:self.feedResult.title]) { + // User didn't change title since last fetch. Will be pre-filled with new title after download + if ([self.view.name.stringValue isEqualToString:self.view.name.placeholderString]) { self.view.name.stringValue = @""; + self.view.name.placeholderString = NSLocalizedString(@"Loading …", nil); } - self.feedError = nil; - self.feedResult = nil; - self.httpResponse = nil; - self.faviconURL = nil; self.previousURL = self.view.url.stringValue; -} - -/** - All properties will be parsed and stored in class variables. - This should avoid unnecessary core data operations if user decides to cancel the edit. - The save operation will only be executed if user clicks on the 'OK' button. - */ -- (void)downloadRSS { - if (self.modalSheet.didCloseAndCancel) - return; - [self preDownload]; - [WebFeed newFeed:self.previousURL askUser:^NSString *(RSHTMLMetadata *meta) { - self.faviconURL = [WebFeed faviconUrlForMetadata:meta]; // we can re-use favicon url if we find one - return [self letUserChooseXmlUrlFromList:meta.feedLinks]; - } block:^(RSParsedFeed *result, NSError *error, NSHTTPURLResponse* response) { - if (self.modalSheet.didCloseAndCancel) - return; - self.didDownloadFeed = YES; - self.feedResult = result; - self.feedError = error; - self.httpResponse = response; - [self postDownload:response.URL.absoluteString]; - }]; + self.memFeed = [[FeedDownload withURL:self.previousURL] startWithDelegate:self]; } /** @@ -176,12 +162,7 @@ @return Either URL string or @c nil if user canceled the selection. */ -- (NSString*)letUserChooseXmlUrlFromList:(NSArray *)list { - if (list.count == 1) { // nothing to choose - // Feeds like https://news.ycombinator.com/ return 503 if URLs are requested too rapidly - //CFRunLoopRunInMode(kCFRunLoopDefaultMode, 1.0, false); // Non-blocking sleep (1s) - return list.firstObject.link; - } +- (NSString*)feedDownload:(FeedDownload*)sender selectFeedFromList:(NSArray*)list { NSMenu *menu = [[NSMenu alloc] initWithTitle:NSLocalizedString(@"Choose feed menu", nil)]; menu.autoenablesItems = NO; for (RSHTMLMetadataFeedLink *fl in list) { @@ -196,62 +177,53 @@ return nil; // user selection canceled } -/** - Update UI TextFields with downloaded values. - Title will be updated if TextField is empty. URL on redirect. - Finally begin favicon download and return control to user (enable 'Done' button). - */ -- (void)postDownload:(NSString*)responseURL { - if (self.modalSheet.didCloseAndCancel) - return; - - BOOL hasError = (self.feedError != nil); - // 1. Stop spinner animation for name field. (keep spinner for URL running until favicon downloaded) - [self.view.spinnerName stopAnimation:nil]; - // 2. If URL was redirected, replace original text field value with new one. (e.g., https redirect) - if (responseURL.length > 0 && ![responseURL isEqualToString:self.previousURL]) { - if (!hasError) { - // If the url has changed and there is an error: - // This probably means the feed URL was resolved, but the successive download returned 5xx error. - // Presumably to prevent site crawlers accessing many pages in quick succession. (delay of 1s does help) - // By not setting previousURL, a second hit on the 'Done' button will retry the resolved URL again. - self.previousURL = responseURL; - } - self.view.url.stringValue = responseURL; +/// If URL was redirected, replace original text field value with new one. (e.g., https redirect) +- (void)feedDownload:(FeedDownload*)sender urlRedirected:(NSString*)newURL { + if (!sender.error) { + // If the url has changed and there is an error: + // This probably means the feed URL was resolved, but the successive download returned 5xx error. + // Presumably to prevent site crawlers accessing many pages in quick succession. (delay of 1s does help) + // By not setting previousURL, a second hit on the 'Done' button will retry the resolved URL again. + self.previousURL = newURL; } - // 3. Copy parsed feed title to text field. (only if user hasn't set anything else yet) - NSString *parsedTitle = self.feedResult.title; - if (parsedTitle.length > 0 && [self.view.name.stringValue isEqualToString:@""]) { - self.view.name.stringValue = parsedTitle; // no damage to replace an empty string + self.view.url.stringValue = newURL; +} + +/// Update UI TextFields with downloaded values. Title updated if TextField is empty, URL if redirect. +- (void)feedDownloadDidFinish:(FeedDownload*)sender { + // Stop spinner for name field but keep running for URL until favicon downloaded + [self.view.spinnerName stopAnimation:nil]; + NSString *newTitle = sender.xmlfeed.title; + self.view.name.placeholderString = newTitle; + if (newTitle.length > 0 && self.view.name.stringValue.length == 0) { + self.view.name.stringValue = newTitle; // only if default title wasn't changed } // TODO: user preference to automatically select refresh interval (selection: None,min,max,avg,median) - [self statsForDownloadObject]; - // 4. Continue with favicon download (or finish with error) + [self statsForDownloadObject:sender.xmlfeed.articles]; + BOOL hasError = (sender.error != nil); self.view.favicon.hidden = hasError; self.view.warningButton.hidden = !hasError; - if (hasError) { - [self finishDownloadWithFavicon]; - } else { - if (!self.faviconURL) - self.faviconURL = self.feedResult.link; - if (self.faviconURL.length == 0) - self.faviconURL = responseURL; - [WebFeed downloadFavicon:self.faviconURL finished:^(NSImage * _Nullable img) { - if (self.modalSheet.didCloseAndCancel) - return; - self.view.favicon.image = img; - [self finishDownloadWithFavicon]; - }]; - } + // Start favicon download + if (hasError) + [self downloadComplete]; + else + self.memIcon = [[sender faviconDownload] startWithDelegate:self]; } /** The last step of the download process. - Stop spinning animation set favivon image preview (right of url bar) and re-enable 'Done' button. + Stop spinning animation, set favivon image (right of url bar), and re-enable 'Done' button. */ -- (void)finishDownloadWithFavicon { - if (self.modalSheet.didCloseAndCancel) - return; +- (void)faviconDownload:(FaviconDownload*)sender didFinish:(nullable NSURL*)path { + // Create image from favicon temporary file location or default icon if no favicon exists. + NSImage *img = path ? [[NSImage alloc] initByReferencingURL:path] : [NSImage imageNamed:RSSImageDefaultRSSIcon]; + self.view.favicon.image = img; + self.faviconFile = path; + [self downloadComplete]; +} + +/// Called regardless of favicon download. +- (void)downloadComplete { [self.view.spinnerURL stopAnimation:nil]; [self.modalSheet setDoneEnabled:YES]; } @@ -259,15 +231,15 @@ #pragma mark - Feed Statistics /// Perform statistics on newly downloaded feed item -- (void)statsForDownloadObject { - NSMutableArray *arr = [NSMutableArray arrayWithCapacity:self.feedResult.articles.count]; - for (RSParsedArticle *a in self.feedResult.articles) { +- (void)statsForDownloadObject:(NSArray*)articles { + NSMutableArray *arr = [NSMutableArray arrayWithCapacity:articles.count]; + for (RSParsedArticle *a in articles) { NSDate *d = a.datePublished; if (!d) d = a.dateModified; if (!d) continue; [arr addObject:d]; } - [self appendViewWithFeedStatistics:arr count:self.feedResult.articles.count]; + [self appendViewWithFeedStatistics:arr count:articles.count]; } /// Perform statistics on stored core data object @@ -301,8 +273,10 @@ /// Window delegate will be only called on button 'Done'. -- (BOOL)windowShouldClose:(NSWindow *)sender { - if (![self.previousURL isEqualToString:self.view.url.stringValue]) { +- (BOOL)windowShouldClose:(ModalSheet*)sender { + if (sender.didTapCancel) { + [self cancelDownloads]; + } else if (![self.previousURL isEqualToString:self.view.url.stringValue]) { // 'Done' button [[NSNotificationCenter defaultCenter] postNotificationName:NSControlTextDidEndEditingNotification object:self.view.url]; return NO; } @@ -310,8 +284,8 @@ } /// Whenever the user finished entering the url (return key or focus change) perform a download request. -- (void)controlTextDidEndEditing:(NSNotification *)obj { - if (obj.object == self.view.url) { +- (void)controlTextDidEndEditing:(NSNotification*)obj { + if (obj.object == self.view.url && !self.modalSheet.didTapCancel) { if (![self.previousURL isEqualToString:self.view.url.stringValue]) { [self downloadRSS]; } @@ -320,15 +294,18 @@ /// Warning button next to url text field. Will be visible if an error occurs during download. - (void)didClickWarningButton:(NSButton*)sender { - if (!self.feedError) - return; + NSError *err = self.memFeed.error; + if (!err) return; // show reload button if server is temporarily offline (any 5xx server error) - BOOL serverError = (self.feedError.domain == NSURLErrorDomain && self.feedError.code == NSURLErrorBadServerResponse); + BOOL serverError = (err.code == NSURLErrorBadServerResponse && err.domain == NSURLErrorDomain); self.view.warningReload.hidden = !serverError; // set error description as text - self.view.warningText.objectValue = self.feedError.localizedDescription; + if (serverError) + self.view.warningText.stringValue = [NSString stringWithFormat:@"%@\n––––\n%@", err.localizedDescription, err.localizedRecoverySuggestion]; + else + self.view.warningText.objectValue = err.localizedDescription; NSSize newSize = self.view.warningText.fittingSize; // width is limited by the textfield's preferred width newSize.width += 2 * self.view.warningText.frame.origin.x; // the padding newSize.height += 2 * self.view.warningText.frame.origin.y; @@ -345,9 +322,11 @@ @end - -#pragma mark - ModalGroupEdit - - +// ################################################################ +// # +// # MARK: - ModalGroupEdit - +// # +// ################################################################ @implementation ModalGroupEdit /// Init view and set group name if edeting an already existing object. diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeeds+DragDrop.m b/baRSS/Preferences/Feeds Tab/SettingsFeeds+DragDrop.m index 3f843e8..7eeb1f7 100644 --- a/baRSS/Preferences/Feeds Tab/SettingsFeeds+DragDrop.m +++ b/baRSS/Preferences/Feeds Tab/SettingsFeeds+DragDrop.m @@ -144,9 +144,8 @@ const NSPasteboardType dragReorder = @"de.relikd.baRSS.drag-reorder"; return; } // Get list of feeds, and root level selection - NSUInteger count = moc.insertedObjects.count; - NSMutableArray *selection = [NSMutableArray arrayWithCapacity:count]; - NSMutableArray *feedsList = [NSMutableArray arrayWithCapacity:count]; + NSMutableArray *selection = [NSMutableArray array]; + NSMutableArray *feedsList = [NSMutableArray array]; for (__kindof NSManagedObject *obj in moc.insertedObjects) { if ([obj isKindOfClass:[Feed class]]) { [feedsList addObject:obj]; // list of feeds that need download @@ -161,8 +160,10 @@ const NSPasteboardType dragReorder = @"de.relikd.baRSS.drag-reorder"; if (selection.count > 0) [self.dataStore setSelectionIndexPaths:[selection sortedArrayUsingSelector:@selector(compare:)]]; - [UpdateScheduler downloadList:feedsList background:NO finally:^{ + [UpdateScheduler downloadList:feedsList userInitiated:YES finally:^{ [self endCoreDataChangeUndoEmpty:NO forceUndo:NO]; + for (Feed *f in feedsList) + [moc refreshObject:f.group mergeChanges:NO]; // fixes blank icon if imported with no inet conn [UpdateScheduler scheduleNextFeed]; }]; } @@ -243,7 +244,7 @@ const NSPasteboardType dragReorder = @"de.relikd.baRSS.drag-reorder"; return result; } -NS_INLINE BOOL IndexPathIsChildOfParent(NSIndexPath *child, NSIndexPath *parent) { +static inline BOOL IndexPathIsChildOfParent(NSIndexPath *child, NSIndexPath *parent) { while (child.length > parent.length) child = [child indexPathByRemovingLastIndex]; return [child isEqualTo:parent]; diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeeds.m b/baRSS/Preferences/Feeds Tab/SettingsFeeds.m index 7cf524a..ab72b99 100644 --- a/baRSS/Preferences/Feeds Tab/SettingsFeeds.m +++ b/baRSS/Preferences/Feeds Tab/SettingsFeeds.m @@ -47,9 +47,9 @@ - (void)viewDidLoad { [super viewDidLoad]; // Register for notifications - RegisterNotification(kNotificationFeedUpdated, @selector(feedUpdated:), self); + RegisterNotification(kNotificationArticlesUpdated, @selector(feedUpdated:), self); RegisterNotification(kNotificationFeedIconUpdated, @selector(feedUpdated:), self); - RegisterNotification(kNotificationGroupInserted, @selector(groupInserted:), self); + RegisterNotification(kNotificationFeedGroupInserted, @selector(feedGroupInserted:), self); // Status bar RegisterNotification(kNotificationScheduleTimerChanged, @selector(updateStatusInfo), self); RegisterNotification(kNotificationNetworkStatusChanged, @selector(updateStatusInfo), self); @@ -58,6 +58,8 @@ - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; + NSUInteger c = [StoreCoordinator cleanupFavicons]; + if (c > 0) NSLog(@"Removed %lu unreferenced favicons", c); } /// Initialize status info timer @@ -97,10 +99,8 @@ self.dataStore.sortDescriptors = [NSArray arrayWithObject:[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]]; NSError *error; - BOOL ok = [self.dataStore fetchWithRequest:nil merge:NO error:&error]; - if (!ok || error) { - [[NSApplication sharedApplication] presentError:error]; - } + [self.dataStore fetchWithRequest:nil merge:NO error:&error]; + if (error) [NSApp presentError:error]; } /** @@ -160,7 +160,7 @@ } /// Callback method fired when feed is inserted via a 'feed://' url -- (void)groupInserted:(NSNotification*)notify { +- (void)feedGroupInserted:(NSNotification*)notify { [self.dataStore fetch:self]; } @@ -312,7 +312,7 @@ } if ([self endCoreDataChangeUndoEmpty:YES forceUndo:(returnCode != NSModalResponseOK)]) { if (!flag) [UpdateScheduler scheduleNextFeed]; // only for feed edit - [self.dataStore rearrangeObjects]; // update display, edited title or icon + [self.dataStore.managedObjectContext refreshObject:fg mergeChanges:NO]; // update title & icon } }]; } diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeedsView.m b/baRSS/Preferences/Feeds Tab/SettingsFeedsView.m index 57dfa01..1ed99d7 100644 --- a/baRSS/Preferences/Feeds Tab/SettingsFeedsView.m +++ b/baRSS/Preferences/Feeds Tab/SettingsFeedsView.m @@ -203,7 +203,7 @@ NSUserInterfaceItemIdentifier const CustomCellName = @"NameColumnCell"; } - (void)setObjectValue:(FeedGroup*)fg { - self.textField.objectValue = fg.name; + self.textField.objectValue = fg.anyName; self.imageView.image = fg.iconImage16; } diff --git a/baRSS/Preferences/Helper/ModalSheet.h b/baRSS/Preferences/Helper/ModalSheet.h index 40c7a84..c56abba 100644 --- a/baRSS/Preferences/Helper/ModalSheet.h +++ b/baRSS/Preferences/Helper/ModalSheet.h @@ -23,7 +23,7 @@ @import Cocoa; @interface ModalSheet : NSPanel -@property (readonly) BOOL didCloseAndCancel; +@property (readonly) BOOL didTapCancel; - (instancetype)initWithContentRect:(NSRect)contentRect styleMask:(NSWindowStyleMask)style backing:(NSBackingStoreType)backingStoreType defer:(BOOL)flag NS_UNAVAILABLE; - (instancetype)initWithView:(NSView*)content NS_DESIGNATED_INITIALIZER; diff --git a/baRSS/Preferences/Helper/ModalSheet.m b/baRSS/Preferences/Helper/ModalSheet.m index c17f008..57cdc0c 100644 --- a/baRSS/Preferences/Helper/ModalSheet.m +++ b/baRSS/Preferences/Helper/ModalSheet.m @@ -81,14 +81,14 @@ /** Called after user has clicked the 'Done' (Return) or 'Cancel' (Esc) button. - In the later case set @c .didCloseAndCancel @c = @c YES + In the later case set @c .didTapCancel @c = @c YES */ - (void)didTapButton:(NSButton*)sender { BOOL successful = (sender.tag == 42); // 'Done' button - if (successful && self.respondToShouldClose && ![self.delegate windowShouldClose:self]) { + _didTapCancel = !successful; + if (self.respondToShouldClose && ![self.delegate windowShouldClose:self]) { return; } - _didCloseAndCancel = !successful; // Save modal view width for next time CGFloat w = NSWidth(self.contentView.frame) - 2 * PAD_WIN; [[NSUserDefaults standardUserDefaults] setInteger:(NSInteger)w forKey:@"modalSheetWidth"]; diff --git a/baRSS/Status Bar Menu/BarMenu.m b/baRSS/Status Bar Menu/BarMenu.m index 9061765..1ed91e6 100644 --- a/baRSS/Status Bar Menu/BarMenu.m +++ b/baRSS/Status Bar Menu/BarMenu.m @@ -28,6 +28,7 @@ #import "MapUnreadTotal.h" #import "StoreCoordinator.h" #import "Feed+Ext.h" +#import "FeedGroup+Ext.h" #import "FeedArticle+Ext.h" @@ -45,7 +46,7 @@ // TODO: move unread counts to status item and keep in sync when changing feeds in preferences self.unreadMap = [[MapUnreadTotal alloc] initWithCoreData: [StoreCoordinator countAggregatedUnread]]; // Register for notifications - RegisterNotification(kNotificationFeedUpdated, @selector(feedUpdated:), self); + RegisterNotification(kNotificationArticlesUpdated, @selector(articlesUpdated:), self); RegisterNotification(kNotificationFeedIconUpdated, @selector(feedIconUpdated:), self); return self; } @@ -129,21 +130,15 @@ - (void)updateFeedMenuItem:(NSManagedObjectID*)oid withBlock:(void(^)(Feed *feed, NSMenuItem *item))block { NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; Feed *feed = [moc objectWithID:oid]; - if (![feed isKindOfClass:[Feed class]]) { - [moc reset]; - return; + if ([feed isKindOfClass:[Feed class]]) { + NSMenuItem *item = [self.statusItem.mainMenu deepestItemWithPath:feed.indexPath]; + if (item) block(feed, item); } - NSMenuItem *item = [self.statusItem.mainMenu deepestItemWithPath:feed.indexPath]; - if (!item) { - [moc reset]; - return; - } - block(feed, item); [moc reset]; } /// Callback method fired when feed has been updated in the background. -- (void)feedUpdated:(NSNotification*)notify { +- (void)articlesUpdated:(NSNotification*)notify { [self updateFeedMenuItem:notify.object withBlock:^(Feed *feed, NSMenuItem *item) { // 1. update in-memory unread count UnreadTotal *updated = [UnreadTotal new]; @@ -154,8 +149,7 @@ [self.unreadMap updateAllCounts:updated forPath:feed.indexPath]; // 2. rebuild articles menu if it is open if (item.submenu.isFeedMenu) { // menu item is visible - if (feed.group.name) - item.title = feed.group.name; // will replace (no title) + item.title = feed.group.anyName; // will replace (no title) item.image = [feed iconImage16]; item.enabled = (feed.articles.count > 0); if (item.submenu.numberOfItems > 0) { // replace articles menu