Refactoring Status Menu

This commit is contained in:
relikd
2019-02-10 19:39:51 +01:00
parent cd0a1a3fd7
commit f2cca57fbb
39 changed files with 1491 additions and 1254 deletions

View File

@@ -11,7 +11,6 @@
54195883218A061100581B79 /* Feed+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54195882218A061100581B79 /* Feed+Ext.m */; }; 54195883218A061100581B79 /* Feed+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54195882218A061100581B79 /* Feed+Ext.m */; };
54195886218E1BDB00581B79 /* NSMenu+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54195885218E1BDB00581B79 /* NSMenu+Ext.m */; }; 54195886218E1BDB00581B79 /* NSMenu+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54195885218E1BDB00581B79 /* NSMenu+Ext.m */; };
54209E942117325100F3B5EF /* DrawImage.m in Sources */ = {isa = PBXBuildFile; fileRef = 54209E932117325100F3B5EF /* DrawImage.m */; }; 54209E942117325100F3B5EF /* DrawImage.m in Sources */ = {isa = PBXBuildFile; fileRef = 54209E932117325100F3B5EF /* DrawImage.m */; };
543695D8214F1F2700DA979D /* NSMenuItem+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 543695D7214F1F2700DA979D /* NSMenuItem+Ext.m */; };
544936FB21F1E66100DEE9AA /* Statistics.m in Sources */ = {isa = PBXBuildFile; fileRef = 544936FA21F1E66100DEE9AA /* Statistics.m */; }; 544936FB21F1E66100DEE9AA /* Statistics.m in Sources */ = {isa = PBXBuildFile; fileRef = 544936FA21F1E66100DEE9AA /* Statistics.m */; };
544B011A2114B41200386E5C /* ModalSheet.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B01192114B41200386E5C /* ModalSheet.m */; }; 544B011A2114B41200386E5C /* ModalSheet.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B01192114B41200386E5C /* ModalSheet.m */; };
544B011D2114EE9100386E5C /* AppHook.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B011C2114EE9100386E5C /* AppHook.m */; }; 544B011D2114EE9100386E5C /* AppHook.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B011C2114EE9100386E5C /* AppHook.m */; };
@@ -25,10 +24,14 @@
546FC4472118A8E6007CC3A3 /* Preferences.xib in Resources */ = {isa = PBXBuildFile; fileRef = 546FC4462118A8E6007CC3A3 /* Preferences.xib */; }; 546FC4472118A8E6007CC3A3 /* Preferences.xib in Resources */ = {isa = PBXBuildFile; fileRef = 546FC4462118A8E6007CC3A3 /* Preferences.xib */; };
5477D34E21233C62002BA27F /* FeedGroup+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 5477D34D21233C62002BA27F /* FeedGroup+Ext.m */; }; 5477D34E21233C62002BA27F /* FeedGroup+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 5477D34D21233C62002BA27F /* FeedGroup+Ext.m */; };
5496B511214D6275003ED4ED /* UserPrefs.m in Sources */ = {isa = PBXBuildFile; fileRef = 5496B510214D6275003ED4ED /* UserPrefs.m */; }; 5496B511214D6275003ED4ED /* UserPrefs.m in Sources */ = {isa = PBXBuildFile; fileRef = 5496B510214D6275003ED4ED /* UserPrefs.m */; };
54A07A7F220E04CF00082C51 /* NSFetchRequest+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54A07A7E220E04CF00082C51 /* NSFetchRequest+Ext.m */; };
54A07A82220E723D00082C51 /* MapUnreadTotal.m in Sources */ = {isa = PBXBuildFile; fileRef = 54A07A81220E723D00082C51 /* MapUnreadTotal.m */; };
54ACC28621061B3C0020715F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 54ACC28521061B3C0020715F /* Assets.xcassets */; }; 54ACC28621061B3C0020715F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 54ACC28521061B3C0020715F /* Assets.xcassets */; };
54ACC28C21061B3C0020715F /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC28B21061B3C0020715F /* main.m */; }; 54ACC28C21061B3C0020715F /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC28B21061B3C0020715F /* main.m */; };
54ACC29521061E270020715F /* FeedDownload.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC29421061E270020715F /* FeedDownload.m */; }; 54ACC29521061E270020715F /* FeedDownload.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC29421061E270020715F /* FeedDownload.m */; };
54ACC29821061FBA0020715F /* Preferences.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC29721061FBA0020715F /* Preferences.m */; }; 54ACC29821061FBA0020715F /* Preferences.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC29721061FBA0020715F /* Preferences.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 */; }; 54BB048921FD2AB500C303A5 /* NSDate+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54BB048821FD2AB500C303A5 /* NSDate+Ext.m */; };
54CC04382162532A00A48795 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 54CC04372162532A00A48795 /* main.m */; }; 54CC04382162532A00A48795 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 54CC04372162532A00A48795 /* main.m */; };
54CC043E2162566900A48795 /* baRSS-Helper.app in CopyFiles */ = {isa = PBXBuildFile; fileRef = 54CC042C2162532800A48795 /* baRSS-Helper.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 54CC043E2162566900A48795 /* baRSS-Helper.app in CopyFiles */ = {isa = PBXBuildFile; fileRef = 54CC042C2162532800A48795 /* baRSS-Helper.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@@ -86,8 +89,6 @@
541958872190FF1200581B79 /* Constants.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Constants.h; sourceTree = "<group>"; }; 541958872190FF1200581B79 /* Constants.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Constants.h; sourceTree = "<group>"; };
54209E922117325100F3B5EF /* DrawImage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DrawImage.h; sourceTree = "<group>"; }; 54209E922117325100F3B5EF /* DrawImage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DrawImage.h; sourceTree = "<group>"; };
54209E932117325100F3B5EF /* DrawImage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DrawImage.m; sourceTree = "<group>"; }; 54209E932117325100F3B5EF /* DrawImage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DrawImage.m; sourceTree = "<group>"; };
543695D6214F1F2700DA979D /* NSMenuItem+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSMenuItem+Ext.h"; sourceTree = "<group>"; };
543695D7214F1F2700DA979D /* NSMenuItem+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSMenuItem+Ext.m"; sourceTree = "<group>"; };
544936F921F1E66100DEE9AA /* Statistics.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Statistics.h; sourceTree = "<group>"; }; 544936F921F1E66100DEE9AA /* Statistics.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Statistics.h; sourceTree = "<group>"; };
544936FA21F1E66100DEE9AA /* Statistics.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Statistics.m; sourceTree = "<group>"; }; 544936FA21F1E66100DEE9AA /* Statistics.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Statistics.m; sourceTree = "<group>"; };
544B01182114B41200386E5C /* ModalSheet.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ModalSheet.h; sourceTree = "<group>"; }; 544B01182114B41200386E5C /* ModalSheet.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ModalSheet.h; sourceTree = "<group>"; };
@@ -107,6 +108,10 @@
5477D34D21233C62002BA27F /* FeedGroup+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "FeedGroup+Ext.m"; sourceTree = "<group>"; }; 5477D34D21233C62002BA27F /* FeedGroup+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "FeedGroup+Ext.m"; sourceTree = "<group>"; };
5496B50F214D6275003ED4ED /* UserPrefs.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UserPrefs.h; sourceTree = "<group>"; }; 5496B50F214D6275003ED4ED /* UserPrefs.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UserPrefs.h; sourceTree = "<group>"; };
5496B510214D6275003ED4ED /* UserPrefs.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UserPrefs.m; sourceTree = "<group>"; }; 5496B510214D6275003ED4ED /* UserPrefs.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UserPrefs.m; sourceTree = "<group>"; };
54A07A7D220E04CF00082C51 /* NSFetchRequest+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSFetchRequest+Ext.h"; sourceTree = "<group>"; };
54A07A7E220E04CF00082C51 /* NSFetchRequest+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSFetchRequest+Ext.m"; sourceTree = "<group>"; };
54A07A80220E723D00082C51 /* MapUnreadTotal.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MapUnreadTotal.h; sourceTree = "<group>"; };
54A07A81220E723D00082C51 /* MapUnreadTotal.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MapUnreadTotal.m; sourceTree = "<group>"; };
54ACC27C21061B3B0020715F /* baRSS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = baRSS.app; sourceTree = BUILT_PRODUCTS_DIR; }; 54ACC27C21061B3B0020715F /* baRSS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = baRSS.app; sourceTree = BUILT_PRODUCTS_DIR; };
54ACC28321061B3B0020715F /* DBv1.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = DBv1.xcdatamodel; sourceTree = "<group>"; }; 54ACC28321061B3B0020715F /* DBv1.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = DBv1.xcdatamodel; sourceTree = "<group>"; };
54ACC28521061B3C0020715F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 54ACC28521061B3C0020715F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@@ -116,6 +121,10 @@
54ACC29421061E270020715F /* FeedDownload.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FeedDownload.m; sourceTree = "<group>"; }; 54ACC29421061E270020715F /* FeedDownload.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FeedDownload.m; sourceTree = "<group>"; };
54ACC29621061FBA0020715F /* Preferences.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Preferences.h; sourceTree = "<group>"; }; 54ACC29621061FBA0020715F /* Preferences.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Preferences.h; sourceTree = "<group>"; };
54ACC29721061FBA0020715F /* Preferences.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = Preferences.m; sourceTree = "<group>"; }; 54ACC29721061FBA0020715F /* Preferences.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = Preferences.m; sourceTree = "<group>"; };
54B749D82204A85C0022CC6D /* BarStatusItem.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BarStatusItem.h; sourceTree = "<group>"; };
54B749D92204A85C0022CC6D /* BarStatusItem.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BarStatusItem.m; sourceTree = "<group>"; };
54B749DE220635BE0022CC6D /* FeedArticle+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FeedArticle+Ext.h"; sourceTree = "<group>"; };
54B749DF220635CD0022CC6D /* FeedArticle+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "FeedArticle+Ext.m"; sourceTree = "<group>"; };
54BB048721FD2AB500C303A5 /* NSDate+Ext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSDate+Ext.h"; sourceTree = "<group>"; }; 54BB048721FD2AB500C303A5 /* NSDate+Ext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSDate+Ext.h"; sourceTree = "<group>"; };
54BB048821FD2AB500C303A5 /* NSDate+Ext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSDate+Ext.m"; sourceTree = "<group>"; }; 54BB048821FD2AB500C303A5 /* NSDate+Ext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSDate+Ext.m"; sourceTree = "<group>"; };
54CC042C2162532800A48795 /* baRSS-Helper.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "baRSS-Helper.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 54CC042C2162532800A48795 /* baRSS-Helper.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "baRSS-Helper.app"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -153,28 +162,17 @@
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
54195880218A05E700581B79 /* Categories */ = {
isa = PBXGroup;
children = (
5477D34C21233C62002BA27F /* FeedGroup+Ext.h */,
5477D34D21233C62002BA27F /* FeedGroup+Ext.m */,
54195881218A061100581B79 /* Feed+Ext.h */,
54195882218A061100581B79 /* Feed+Ext.m */,
540F704321B6C16C0022E69D /* FeedMeta+Ext.h */,
540F704421B6C16C0022E69D /* FeedMeta+Ext.m */,
);
path = Categories;
sourceTree = "<group>";
};
541A90EF21257D4F002680A6 /* Status Bar Menu */ = { 541A90EF21257D4F002680A6 /* Status Bar Menu */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
543695D6214F1F2700DA979D /* NSMenuItem+Ext.h */, 54B749D82204A85C0022CC6D /* BarStatusItem.h */,
543695D7214F1F2700DA979D /* NSMenuItem+Ext.m */, 54B749D92204A85C0022CC6D /* BarStatusItem.m */,
54195884218E1BDB00581B79 /* NSMenu+Ext.h */,
54195885218E1BDB00581B79 /* NSMenu+Ext.m */,
54FE73D1212316CD003EAC65 /* BarMenu.h */, 54FE73D1212316CD003EAC65 /* BarMenu.h */,
54FE73D2212316CD003EAC65 /* BarMenu.m */, 54FE73D2212316CD003EAC65 /* BarMenu.m */,
54A07A80220E723D00082C51 /* MapUnreadTotal.h */,
54A07A81220E723D00082C51 /* MapUnreadTotal.m */,
54195884218E1BDB00581B79 /* NSMenu+Ext.h */,
54195885218E1BDB00581B79 /* NSMenu+Ext.m */,
); );
path = "Status Bar Menu"; path = "Status Bar Menu";
sourceTree = "<group>"; sourceTree = "<group>";
@@ -184,6 +182,8 @@
children = ( children = (
54209E922117325100F3B5EF /* DrawImage.h */, 54209E922117325100F3B5EF /* DrawImage.h */,
54209E932117325100F3B5EF /* DrawImage.m */, 54209E932117325100F3B5EF /* DrawImage.m */,
54ACC29321061E270020715F /* FeedDownload.h */,
54ACC29421061E270020715F /* FeedDownload.m */,
544936F921F1E66100DEE9AA /* Statistics.h */, 544936F921F1E66100DEE9AA /* Statistics.h */,
544936FA21F1E66100DEE9AA /* Statistics.m */, 544936FA21F1E66100DEE9AA /* Statistics.m */,
54BB048721FD2AB500C303A5 /* NSDate+Ext.h */, 54BB048721FD2AB500C303A5 /* NSDate+Ext.h */,
@@ -228,6 +228,25 @@
path = Preferences; path = Preferences;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
54A07A8322105E0800082C51 /* Core Data */ = {
isa = PBXGroup;
children = (
54A07A7D220E04CF00082C51 /* NSFetchRequest+Ext.h */,
54A07A7E220E04CF00082C51 /* NSFetchRequest+Ext.m */,
54FE73CE21220DEC003EAC65 /* StoreCoordinator.h */,
54FE73CF21220DEC003EAC65 /* StoreCoordinator.m */,
54195881218A061100581B79 /* Feed+Ext.h */,
54195882218A061100581B79 /* Feed+Ext.m */,
5477D34C21233C62002BA27F /* FeedGroup+Ext.h */,
5477D34D21233C62002BA27F /* FeedGroup+Ext.m */,
540F704321B6C16C0022E69D /* FeedMeta+Ext.h */,
540F704421B6C16C0022E69D /* FeedMeta+Ext.m */,
54B749DE220635BE0022CC6D /* FeedArticle+Ext.h */,
54B749DF220635CD0022CC6D /* FeedArticle+Ext.m */,
);
path = "Core Data";
sourceTree = "<group>";
};
54ACC27321061B3B0020715F = { 54ACC27321061B3B0020715F = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -255,12 +274,8 @@
544B011C2114EE9100386E5C /* AppHook.m */, 544B011C2114EE9100386E5C /* AppHook.m */,
541958872190FF1200581B79 /* Constants.h */, 541958872190FF1200581B79 /* Constants.h */,
541A90EF21257D4F002680A6 /* Status Bar Menu */, 541A90EF21257D4F002680A6 /* Status Bar Menu */,
54FE73CE21220DEC003EAC65 /* StoreCoordinator.h */, 54A07A8322105E0800082C51 /* Core Data */,
54FE73CF21220DEC003EAC65 /* StoreCoordinator.m */,
54ACC29321061E270020715F /* FeedDownload.h */,
54ACC29421061E270020715F /* FeedDownload.m */,
544936F721F1E51E00DEE9AA /* Helper */, 544936F721F1E51E00DEE9AA /* Helper */,
54195880218A05E700581B79 /* Categories */,
546FC44D2118B357007CC3A3 /* Preferences */, 546FC44D2118B357007CC3A3 /* Preferences */,
54ACC28521061B3C0020715F /* Assets.xcassets */, 54ACC28521061B3C0020715F /* Assets.xcassets */,
54ACC28A21061B3C0020715F /* Info.plist */, 54ACC28A21061B3C0020715F /* Info.plist */,
@@ -411,7 +426,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
543695D8214F1F2700DA979D /* NSMenuItem+Ext.m in Sources */, 54B749E0220636200022CC6D /* FeedArticle+Ext.m in Sources */,
54F39C2E210BE1F700AEE730 /* DBv1.xcdatamodeld in Sources */, 54F39C2E210BE1F700AEE730 /* DBv1.xcdatamodeld in Sources */,
544B011D2114EE9100386E5C /* AppHook.m in Sources */, 544B011D2114EE9100386E5C /* AppHook.m in Sources */,
546FC44321189975007CC3A3 /* SettingsGeneral.m in Sources */, 546FC44321189975007CC3A3 /* SettingsGeneral.m in Sources */,
@@ -423,15 +438,18 @@
54ACC28C21061B3C0020715F /* main.m in Sources */, 54ACC28C21061B3C0020715F /* main.m in Sources */,
54FE73D3212316CD003EAC65 /* BarMenu.m in Sources */, 54FE73D3212316CD003EAC65 /* BarMenu.m in Sources */,
544B011A2114B41200386E5C /* ModalSheet.m in Sources */, 544B011A2114B41200386E5C /* ModalSheet.m in Sources */,
54A07A82220E723D00082C51 /* MapUnreadTotal.m in Sources */,
54ACC29821061FBA0020715F /* Preferences.m in Sources */, 54ACC29821061FBA0020715F /* Preferences.m in Sources */,
54195886218E1BDB00581B79 /* NSMenu+Ext.m in Sources */, 54195886218E1BDB00581B79 /* NSMenu+Ext.m in Sources */,
54F6025D21C1D4170006D338 /* OpmlExport.m in Sources */, 54F6025D21C1D4170006D338 /* OpmlExport.m in Sources */,
5496B511214D6275003ED4ED /* UserPrefs.m in Sources */, 5496B511214D6275003ED4ED /* UserPrefs.m in Sources */,
54B749DA2204A85C0022CC6D /* BarStatusItem.m in Sources */,
546FC43D21188AD5007CC3A3 /* SettingsFeeds.m in Sources */, 546FC43D21188AD5007CC3A3 /* SettingsFeeds.m in Sources */,
54E88320211B509D00064188 /* ModalFeedEdit.m in Sources */, 54E88320211B509D00064188 /* ModalFeedEdit.m in Sources */,
54195883218A061100581B79 /* Feed+Ext.m in Sources */, 54195883218A061100581B79 /* Feed+Ext.m in Sources */,
54209E942117325100F3B5EF /* DrawImage.m in Sources */, 54209E942117325100F3B5EF /* DrawImage.m in Sources */,
54FE73D021220DEC003EAC65 /* StoreCoordinator.m in Sources */, 54FE73D021220DEC003EAC65 /* StoreCoordinator.m in Sources */,
54A07A7F220E04CF00082C51 /* NSFetchRequest+Ext.m in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View File

@@ -23,9 +23,11 @@
#import <Cocoa/Cocoa.h> #import <Cocoa/Cocoa.h>
#import <CoreData/CoreData.h> #import <CoreData/CoreData.h>
@class BarMenu; @class BarStatusItem;
@interface AppHook : NSApplication <NSApplicationDelegate> @interface AppHook : NSApplication <NSApplicationDelegate>
@property (readonly, strong) BarMenu *barMenu; @property (readonly, strong) BarStatusItem *statusItem;
@property (readonly, strong) NSPersistentContainer *persistentContainer; @property (readonly, strong) NSPersistentContainer *persistentContainer;
- (void)openPreferences;
@end @end

View File

@@ -21,8 +21,13 @@
// SOFTWARE. // SOFTWARE.
#import "AppHook.h" #import "AppHook.h"
#import "BarMenu.h" #import "BarStatusItem.h"
#import "FeedDownload.h" #import "FeedDownload.h"
#import "Preferences.h"
@interface AppHook()
@property (strong) Preferences *prefWindow;
@end
@implementation AppHook @implementation AppHook
@@ -33,7 +38,7 @@
} }
- (void)applicationWillFinishLaunching:(NSNotification *)notification { - (void)applicationWillFinishLaunching:(NSNotification *)notification {
_barMenu = [BarMenu new]; _statusItem = [BarStatusItem new];
NSAppleEventManager *appleEventManager = [NSAppleEventManager sharedAppleEventManager]; NSAppleEventManager *appleEventManager = [NSAppleEventManager sharedAppleEventManager];
[appleEventManager setEventHandler:self andSelector:@selector(handleGetURLEvent:withReplyEvent:) [appleEventManager setEventHandler:self andSelector:@selector(handleGetURLEvent:withReplyEvent:)
forEventClass:kInternetEventClass andEventID:kAEGetURL]; forEventClass:kInternetEventClass andEventID:kAEGetURL];
@@ -60,6 +65,29 @@
} }
#pragma mark - Handle Menu Interaction
/// Called whenever the user activates the preferences (either through menu click or hotkey).
- (void)openPreferences {
if (!self.prefWindow) {
self.prefWindow = [[Preferences alloc] initWithWindowNibName:@"Preferences"];
self.prefWindow.window.title = [NSString stringWithFormat:@"%@ %@", NSProcessInfo.processInfo.processName,
NSLocalizedString(@"Preferences", nil)];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(preferencesClosed:) name:NSWindowWillCloseNotification object:self.prefWindow.window];
}
[NSApp activateIgnoringOtherApps:YES];
[self.prefWindow showWindow:nil];
}
/// Callback method after user closes the preferences window.
- (void)preferencesClosed:(id)sender {
[[NSNotificationCenter defaultCenter] removeObserver:self name:NSWindowWillCloseNotification object:self.prefWindow.window];
self.prefWindow = nil;
[FeedDownload scheduleUpdateForUpcomingFeeds];
}
#pragma mark - Core Data stack #pragma mark - Core Data stack

View File

@@ -21,6 +21,7 @@
// SOFTWARE. // SOFTWARE.
#import "Feed+CoreDataClass.h" #import "Feed+CoreDataClass.h"
#import <Cocoa/Cocoa.h>
@class RSParsedFeed; @class RSParsedFeed;
@@ -28,13 +29,11 @@
// Generator methods / Feed update // Generator methods / Feed update
+ (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context; + (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context;
+ (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc; + (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc;
- (void)calculateAndSetIndexPathString;
- (void)calculateAndSetUnreadCount;
- (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag; - (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag;
- (void)calculateAndSetIndexPathString;
- (NSMenuItem*)newMenuItem;
// Article properties // Article properties
- (NSArray<FeedArticle*>*)sortedArticles; - (NSArray<FeedArticle*>*)sortedArticles;
- (int)markAllItemsRead;
- (int)markAllItemsUnread;
// Icon // Icon
- (NSImage*)iconImage16; - (NSImage*)iconImage16;
- (BOOL)setIconImage:(NSImage*)img; - (BOOL)setIconImage:(NSImage*)img;

View File

@@ -22,11 +22,11 @@
#import "Feed+Ext.h" #import "Feed+Ext.h"
#import "Constants.h" #import "Constants.h"
#import "UserPrefs.h"
#import "DrawImage.h" #import "DrawImage.h"
#import "FeedMeta+Ext.h" #import "FeedMeta+Ext.h"
#import "FeedGroup+Ext.h" #import "FeedGroup+Ext.h"
#import "FeedIcon+CoreDataClass.h" #import "FeedArticle+Ext.h"
#import "FeedArticle+CoreDataClass.h"
#import "StoreCoordinator.h" #import "StoreCoordinator.h"
#import <RSXML/RSXML.h> #import <RSXML/RSXML.h>
@@ -42,7 +42,7 @@
/// Instantiates new @c FeedGroup with @c FEED type, set the update interval to @c 30min and @c sortIndex to last root index. /// 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 { + (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc {
NSInteger lastIndex = [StoreCoordinator numberRootItemsInContext:moc]; NSUInteger lastIndex = [StoreCoordinator countRootItemsInContext:moc];
FeedGroup *fg = [FeedGroup newGroup:FEED inContext:moc]; FeedGroup *fg = [FeedGroup newGroup:FEED inContext:moc];
[fg setParent:nil andSortIndex:(int32_t)lastIndex]; [fg setParent:nil andSortIndex:(int32_t)lastIndex];
[fg.feed.meta setRefreshAndSchedule:kDefaultFeedRefreshInterval]; [fg.feed.meta setRefreshAndSchedule:kDefaultFeedRefreshInterval];
@@ -56,11 +56,24 @@
self.indexPath = pthStr; self.indexPath = pthStr;
} }
/// Reset attributes @c unreadCount by counting number of articles. @note Remember to update global unread count. /// @return Fully initialized @c NSMenuItem with @c title, @c tooltip, @c image, and @c action.
- (void)calculateAndSetUnreadCount { - (NSMenuItem*)newMenuItem {
int32_t unreadCount = (int32_t)[[self.articles valueForKeyPath:@"@sum.unread"] integerValue]; NSMenuItem *item = [NSMenuItem new];
if (self.unreadCount != unreadCount) item.title = self.group.nameOrError;
self.unreadCount = unreadCount; item.toolTip = self.subtitle;
item.enabled = (self.articles.count > 0);
item.image = [self iconImage16];
item.representedObject = self.indexPath;
item.target = [self class];
item.action = @selector(didClickOnMenuItem:);
return item;
}
/// Callback method for @c NSMenuItem. Will open url associated with @c Feed.
+ (void)didClickOnMenuItem:(NSMenuItem*)sender {
NSString *url = [StoreCoordinator urlForFeedWithIndexPath:sender.representedObject];
if (url && url.length > 0)
[UserPrefs openURLsWithPreferredBrowser:@[[NSURL URLWithString:url]]];
} }
@@ -78,16 +91,13 @@
if (self.group.name.length == 0) // in case a blank group was initialized if (self.group.name.length == 0) // in case a blank group was initialized
self.group.name = obj.title; self.group.name = obj.title;
int32_t unreadBefore = self.unreadCount;
// Add and remove articles // Add and remove articles
NSMutableSet<NSString*> *urls = [[self.articles valueForKeyPath:@"link"] mutableCopy]; NSMutableSet<NSString*> *urls = [[self.articles valueForKeyPath:@"link"] mutableCopy];
[self addMissingArticles:obj updateLinks:urls]; // will remove links in 'urls' that should be kept NSInteger diff = [self addMissingArticles:obj updateLinks:urls]; // will remove links in 'urls' that should be kept
[self deleteArticlesWithLink:urls]; // remove old, outdated articles diff -= [self deleteArticlesWithLink:urls]; // remove old, outdated articles
// Get new total article count and post unread-count-change notification // Get new total article count and post unread-count-change notification
if (flag) { if (flag && diff != 0) {
int32_t cDiff = self.unreadCount - unreadBefore; [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged object:@(diff)];
if (cDiff != 0)
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged object:@(cDiff)];
} }
} }
@@ -100,8 +110,8 @@
@param urls Input will be used to identify new articles. Output will contain URLs that aren't present in the feed anymore. @param urls Input will be used to identify new articles. Output will contain URLs that aren't present in the feed anymore.
*/ */
- (void)addMissingArticles:(RSParsedFeed*)obj updateLinks:(NSMutableSet<NSString*>*)urls { - (NSInteger)addMissingArticles:(RSParsedFeed*)obj updateLinks:(NSMutableSet<NSString*>*)urls {
int32_t newOnes = 0; NSInteger newOnes = 0;
int32_t currentIndex = [[self.articles valueForKeyPath:@"@min.sortIndex"] intValue]; int32_t currentIndex = [[self.articles valueForKeyPath:@"@min.sortIndex"] intValue];
FeedArticle *lastInserted = nil; FeedArticle *lastInserted = nil;
BOOL hasGapBetweenNewArticles = NO; BOOL hasGapBetweenNewArticles = NO;
@@ -122,7 +132,9 @@
newOnes -= 1; newOnes -= 1;
} }
hasGapBetweenNewArticles = NO; hasGapBetweenNewArticles = NO;
lastInserted = [self insertArticle:article atIndex:currentIndex]; lastInserted = [FeedArticle newArticle:article inContext:self.managedObjectContext];
lastInserted.sortIndex = currentIndex;
[self addArticlesObject:lastInserted];
} }
currentIndex += 1; currentIndex += 1;
} }
@@ -130,41 +142,20 @@
lastInserted.unread = NO; lastInserted.unread = NO;
newOnes -= 1; newOnes -= 1;
} }
if (newOnes > 0) return newOnes;
self.unreadCount += newOnes; // new articles are by definition unread
}
/**
Create article based on input and insert into core data storage.
*/
- (FeedArticle*)insertArticle:(RSParsedArticle*)entry atIndex:(int32_t)idx {
FeedArticle *fa = [[FeedArticle alloc] initWithEntity:FeedArticle.entity insertIntoManagedObjectContext:self.managedObjectContext];
fa.sortIndex = idx;
fa.unread = YES;
fa.guid = entry.guid;
fa.title = entry.title;
fa.abstract = entry.abstract;
fa.body = entry.body;
fa.author = entry.author;
fa.link = entry.link;
fa.published = entry.datePublished;
if (!fa.published)
fa.published = entry.dateModified;
[self addArticlesObject:fa];
return fa;
} }
/** /**
Delete all items where @c link matches one of the URLs in the @c NSSet. Delete all items where @c link matches one of the URLs in the @c NSSet.
*/ */
- (void)deleteArticlesWithLink:(NSMutableSet<NSString*>*)urls { - (NSUInteger)deleteArticlesWithLink:(NSMutableSet<NSString*>*)urls {
if (!urls || urls.count == 0) if (!urls || urls.count == 0)
return; return 0;
NSUInteger c = 0;
for (FeedArticle *fa in self.articles) { for (FeedArticle *fa in self.articles) {
if ([urls containsObject:fa.link]) { if ([urls containsObject:fa.link]) {
[urls removeObject:fa.link]; [urls removeObject:fa.link];
if (fa.unread) if (fa.unread) ++c;
self.unreadCount -= 1;
// TODO: keep unread articles? // TODO: keep unread articles?
[self.managedObjectContext deleteObject:fa]; [self.managedObjectContext deleteObject:fa];
if (urls.count == 0) if (urls.count == 0)
@@ -175,6 +166,7 @@
if (delArticles.count > 0) { if (delArticles.count > 0) {
[self removeArticles:delArticles]; [self removeArticles:delArticles];
} }
return c;
} }
@@ -201,41 +193,6 @@
return nil; return nil;
} }
/**
For all articles set @c unread @c = @c NO
@return Change in unread count. (0 or negative number)
*/
- (int)markAllItemsRead {
return [self markAllArticlesRead:YES];
}
/**
For all articles set @c unread @c = @c YES
@return Change in unread count. (0 or positive number)
*/
- (int)markAllItemsUnread {
return [self markAllArticlesRead:NO];
}
/**
Mark all articles read or unread and update @c unreadCount
@param readFlag @c YES: mark items read; @c NO: mark items unread
*/
- (int)markAllArticlesRead:(BOOL)readFlag {
for (FeedArticle *fa in self.articles) {
if (fa.unread == readFlag)
fa.unread = !readFlag;
}
int32_t oldCount = self.unreadCount;
int32_t newCount = (readFlag ? 0 : (int32_t)self.articles.count);
if (self.unreadCount != newCount)
self.unreadCount = newCount;
return newCount - oldCount;
}
#pragma mark - Icon - #pragma mark - Icon -
@@ -262,7 +219,7 @@
} }
else else
{ {
static NSImage *defaultRSSIcon; static NSImage *defaultRSSIcon; // TODO: setup imageNamed: for default rss icon
if (!defaultRSSIcon) if (!defaultRSSIcon)
defaultRSSIcon = [RSSIcon iconWithSize:16]; defaultRSSIcon = [RSSIcon iconWithSize:16];
return defaultRSSIcon; return defaultRSSIcon;

View File

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

View File

@@ -0,0 +1,94 @@
//
// 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 "FeedArticle+Ext.h"
#import "Constants.h"
#import "UserPrefs.h"
#import "StoreCoordinator.h"
#import <RSXML/RSParsedArticle.h>
@implementation FeedArticle (Ext)
/// Create new article based on RSXML article input.
+ (instancetype)newArticle:(RSParsedArticle*)entry inContext:(NSManagedObjectContext*)moc {
FeedArticle *fa = [[FeedArticle alloc] initWithEntity:FeedArticle.entity insertIntoManagedObjectContext:moc];
fa.unread = YES;
fa.guid = entry.guid;
fa.title = entry.title;
fa.abstract = entry.abstract;
fa.body = entry.body;
fa.author = entry.author;
fa.link = entry.link;
fa.published = entry.datePublished;
if (!fa.published)
fa.published = entry.dateModified;
return fa;
}
/// @return Full or truncated article title, based on user preference in settings.
- (NSString*)shortArticleName {
NSString *title = self.title;
if (!title) return @"";
// TODO: It should be enough to get user prefs once per menu build
if ([UserPrefs defaultNO:@"feedShortNames"]) {
NSUInteger limit = [UserPrefs shortArticleNamesLimit];
if (title.length > limit)
title = [NSString stringWithFormat:@"%@…", [title substringToIndex:limit-1]];
}
return title;
}
/// @return Fully initialized @c NSMenuItem with @c title, @c tooltip, @c tickmark, and @c action.
- (NSMenuItem*)newMenuItem {
NSMenuItem *item = [NSMenuItem new];
item.title = [self shortArticleName];
item.enabled = (self.link.length > 0);
item.state = (self.unread && [UserPrefs defaultYES:@"feedTickMark"] ? NSControlStateValueOn : NSControlStateValueOff);
//mi.toolTip = item.abstract;
// TODO: Do regex during save, not during display. Its here for testing purposes ...
if (self.abstract.length > 0) {
NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"<[^>]*>" options:kNilOptions error:nil];
item.toolTip = [regex stringByReplacingMatchesInString:self.abstract options:kNilOptions range:NSMakeRange(0, self.abstract.length) withTemplate:@""];
}
item.representedObject = self.objectID;
item.target = [self class];
item.action = @selector(didClickOnMenuItem:);
return item;
}
/// Callback method for @c NSMenuItem. Will open url associated with @c FeedArticle and mark it read.
+ (void)didClickOnMenuItem:(NSMenuItem*)sender {
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
FeedArticle *fa = [moc objectWithID:sender.representedObject];
NSString *url = fa.link;
if (fa.unread) {
fa.unread = NO;
[StoreCoordinator saveContext:moc andParent:YES];
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged object:@-1];
}
[moc reset];
if (url && url.length > 0)
[UserPrefs openURLsWithPreferredBrowser:@[[NSURL URLWithString:url]]];
}
@end

View File

@@ -21,6 +21,7 @@
// SOFTWARE. // SOFTWARE.
#import "FeedGroup+CoreDataClass.h" #import "FeedGroup+CoreDataClass.h"
#import <Cocoa/Cocoa.h>
/// Enum type to distinguish different @c FeedGroup types: @c GROUP, @c FEED, @c SEPARATOR /// Enum type to distinguish different @c FeedGroup types: @c GROUP, @c FEED, @c SEPARATOR
typedef NS_ENUM(int16_t, FeedGroupType) { typedef NS_ENUM(int16_t, FeedGroupType) {
@@ -32,11 +33,13 @@ typedef NS_ENUM(int16_t, FeedGroupType) {
@interface FeedGroup (Ext) @interface FeedGroup (Ext)
/// Overwrites @c type attribute with enum. Use one of: @c GROUP, @c FEED, @c SEPARATOR. /// Overwrites @c type attribute with enum. Use one of: @c GROUP, @c FEED, @c SEPARATOR.
@property (nonatomic) FeedGroupType type; @property (nonatomic) FeedGroupType type;
@property (nonnull, readonly) NSString *nameOrError;
+ (instancetype)newGroup:(FeedGroupType)type inContext:(NSManagedObjectContext*)context; + (instancetype)newGroup:(FeedGroupType)type inContext:(NSManagedObjectContext*)context;
- (void)setParent:(FeedGroup *)parent andSortIndex:(int32_t)sortIndex; - (void)setParent:(FeedGroup *)parent andSortIndex:(int32_t)sortIndex;
- (void)setNameIfChanged:(NSString*)name; - (void)setNameIfChanged:(NSString*)name;
- (NSImage*)groupIconImage16; - (NSImage*)groupIconImage16;
- (NSMenuItem*)newMenuItem;
// Handle children and parents // Handle children and parents
- (NSString*)indexPathString; - (NSString*)indexPathString;
- (NSArray<FeedGroup*>*)sortedChildren; - (NSArray<FeedGroup*>*)sortedChildren;

View File

@@ -59,6 +59,21 @@
return groupIcon; return groupIcon;
} }
/// @return Returns "(error)" if @c self.name is @c nil.
- (nonnull NSString*)nameOrError {
return (self.name ? self.name : NSLocalizedString(@"(error)", nil));
}
/// @return Fully initialized @c NSMenuItem with @c title and @c image.
- (NSMenuItem*)newMenuItem {
NSMenuItem *item = [NSMenuItem new];
item.title = self.nameOrError;
item.enabled = (self.children.count > 0);
item.image = [self groupIconImage16];
item.representedObject = self.objectID;
return item;
}
#pragma mark - Handle Children And Parents - #pragma mark - Handle Children And Parents -

View File

@@ -0,0 +1,38 @@
//
// 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 <CoreData/CoreData.h>
@interface NSFetchRequest<ResultType> (Ext)
// Perform core data request and fetch data
- (NSArray<ResultType>*)fetchAllRows:(NSManagedObjectContext*)moc;
- (NSArray<NSManagedObjectID*>*)fetchIDs:(NSManagedObjectContext*)moc;
- (NSUInteger)fetchCount:(NSManagedObjectContext*)moc;
- (id)fetchFirst:(NSManagedObjectContext*)moc; // limit 1
// Selecting, filtering, sorting results
- (instancetype)select:(NSArray<NSString*>*)cols; // sets .propertiesToFetch
- (instancetype)where:(NSString*)format, ...; // sets .predicate
- (instancetype)sortASC:(NSString*)key; // add .sortDescriptors -> ascending:YES
- (instancetype)sortDESC:(NSString*)key; // add .sortDescriptors -> ascending:NO
- (instancetype)addFunctionExpression:(NSString*)fn onKeyPath:(NSString*)keyPath name:(NSString*)name type:(NSAttributeType)type; // add .propertiesToFetch -> (expressionForFunction:@[expressionForKeyPath:])
@end

View File

@@ -0,0 +1,133 @@
//
// 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 "NSFetchRequest+Ext.h"
@implementation NSFetchRequest (Ext)
/// Perform fetch and return result. If an error occurs, print it to the console.
- (NSArray*)fetchAllRows:(NSManagedObjectContext*)moc {
NSError *err;
NSArray *fetchResults = [moc executeFetchRequest:self error:&err];
if (err) NSLog(@"ERROR: Fetch request failed: %@", err);
//NSLog(@"%@ ==> %@", self, fetchResults); // debugging
return fetchResults;
}
/// Set @c resultType to @c NSManagedObjectIDResultType and return list of object ids.
- (NSArray<NSManagedObjectID*>*)fetchIDs:(NSManagedObjectContext*)moc {
self.includesPropertyValues = NO;
self.resultType = NSManagedObjectIDResultType;
return [self fetchAllRows:moc];
}
/// Set @c limit to @c 1 and fetch first objcect. May return object type or @c NSDictionary if @c resultType @c = @c NSManagedObjectIDResultType.
- (id)fetchFirst:(NSManagedObjectContext*)moc {
self.fetchLimit = 1;
return [[self fetchAllRows:moc] firstObject];
}
/// Convenient method to return the number of rows that match the request.
- (NSUInteger)fetchCount:(NSManagedObjectContext*)moc {
return [moc countForFetchRequest:self error:nil];
}
#pragma mark - Selecting, Filtering, Sorting
/**
Set @c self.propertiesToFetch = @c cols and @c self.resultType = @c NSDictionaryResultType.
@return @c self (e.g., method chaining)
*/
- (instancetype)select:(NSArray<NSString*>*)cols {
self.propertiesToFetch = cols;
self.resultType = NSDictionaryResultType;
return self;
}
/**
Set @c self.predicate = [NSPredicate predicateWithFormat: @c format ]
@return @c self (e.g., method chaining)
*/
- (instancetype)where:(NSString*)format, ... {
va_list arguments;
va_start(arguments, format);
self.predicate = [NSPredicate predicateWithFormat:format arguments:arguments];
va_end(arguments);
return self;
}
/**
Add new [NSSortDescriptor sortDescriptorWithKey: @c key ascending:YES] to @c self.sortDescriptors.
@return @c self (e.g., method chaining)
*/
- (instancetype)sortASC:(NSString*)key {
[self addSortingKey:key asc:YES];
return self;
}
/**
Add new [NSSortDescriptor sortDescriptorWithKey: @c key ascending:NO] to @c self.sortDescriptors.
@return @c self (e.g., method chaining)
*/
- (instancetype)sortDESC:(NSString*)key {
[self addSortingKey:key asc:NO];
return self;
}
/**
Add new [NSExpression expressionForFunction: @c fn arguments: [NSExpression expressionForKeyPath: @c keyPath ]] to @c self.propertiesToFetch.
Also set @c self.includesPropertyValues @c = @c NO and @c self.resultType @c = @c NSDictionaryResultType.
@return @c self (e.g., method chaining)
*/
- (instancetype)addFunctionExpression:(NSString*)fn onKeyPath:(NSString*)keyPath name:(NSString*)name type:(NSAttributeType)type {
[self addExpression:[NSExpression expressionForFunction:fn arguments:@[[NSExpression expressionForKeyPath:keyPath]]] name:name type:type];
return self;
}
#pragma mark - Helper
/// Add @c NSSortDescriptor to existing list of @c sortDescriptors.
- (void)addSortingKey:(NSString*)key asc:(BOOL)flag {
NSSortDescriptor *sd = [NSSortDescriptor sortDescriptorWithKey:key ascending:flag];
if (!self.sortDescriptors) {
self.sortDescriptors = @[ sd ];
} else {
self.sortDescriptors = [self.sortDescriptors arrayByAddingObject:sd];
}
}
/// Add @c NSExpressionDescription to existing list of @c propertiesToFetch.
- (void)addExpression:(NSExpression*)exp name:(NSString*)name type:(NSAttributeType)type {
self.includesPropertyValues = NO;
self.resultType = NSDictionaryResultType;
NSExpressionDescription *expDesc = [[NSExpressionDescription alloc] init];
[expDesc setName:name];
[expDesc setExpression:exp];
[expDesc setExpressionResultType:type];
if (!self.propertiesToFetch) {
self.propertiesToFetch = @[ expDesc ];
} else {
self.propertiesToFetch = [self.propertiesToFetch arrayByAddingObject:expDesc];
}
}
@end

View File

@@ -27,19 +27,29 @@
// Managing contexts // Managing contexts
+ (NSManagedObjectContext*)createChildContext; + (NSManagedObjectContext*)createChildContext;
+ (void)saveContext:(NSManagedObjectContext*)context andParent:(BOOL)flag; + (void)saveContext:(NSManagedObjectContext*)context andParent:(BOOL)flag;
// Feed update // Feed update
+ (NSArray<Feed*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc;
+ (NSDate*)nextScheduledUpdate; + (NSDate*)nextScheduledUpdate;
// Main menu display + (NSArray<Feed*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc;
+ (NSInteger)unreadCountForIndexPathString:(NSString*)str;
+ (NSArray*)sortedObjectIDsForParent:(id)parent isFeed:(BOOL)flag inContext:(NSManagedObjectContext*)moc; // Count elements
// OPML import & export + (NSUInteger)countTotalUnread;
+ (NSInteger)numberRootItemsInContext:(NSManagedObjectContext*)moc; + (NSUInteger)countRootItemsInContext:(NSManagedObjectContext*)moc;
+ (NSArray<FeedGroup*>*)sortedListOfRootObjectsInContext:(NSManagedObjectContext*)moc; + (NSArray<NSDictionary*>*)countAggregatedUnread;
// Restore sound state
+ (NSUInteger)deleteAllGroups; // Get List Of Elements
+ (NSUInteger)deleteUnreferenced; + (NSArray<FeedGroup*>*)sortedFeedGroupsWithParent:(id)parent inContext:(NSManagedObjectContext*)moc;
+ (void)restoreFeedCountsAndIndexPaths; + (NSArray<FeedArticle*>*)sortedArticlesWithParent:(id)parent inContext:(NSManagedObjectContext*)moc;
+ (NSArray<Feed*>*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc; + (NSArray<Feed*>*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc;
+ (NSArray<Feed*>*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc; + (NSArray<Feed*>*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc;
+ (Feed*)feedWithIndexPath:(nonnull NSString*)path inContext:(NSManagedObjectContext*)moc;
+ (NSString*)urlForFeedWithIndexPath:(nonnull NSString*)path;
// Unread articles list & mark articled read
+ (NSArray<FeedArticle*>*)articlesAtPath:(nullable NSString*)path isFeed:(BOOL)feedFlag sorted:(BOOL)sortFlag unread:(BOOL)readFlag inContext:(NSManagedObjectContext*)moc limit:(NSUInteger)limit;
// Restore sound state
+ (void)restoreFeedIndexPaths;
+ (NSUInteger)deleteUnreferenced;
+ (NSUInteger)deleteAllGroups;
@end @end

View File

@@ -0,0 +1,253 @@
//
// 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 "StoreCoordinator.h"
#import "NSFetchRequest+Ext.h"
#import "AppHook.h"
#import "Feed+Ext.h"
@implementation StoreCoordinator
#pragma mark - Managing contexts
/// @return The application main persistent context.
+ (NSManagedObjectContext*)getMainContext {
return [(AppHook*)NSApp persistentContainer].viewContext;
}
/// New child context with @c NSMainQueueConcurrencyType and without undo manager.
+ (NSManagedObjectContext*)createChildContext {
NSManagedObjectContext *context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType];
[context setParentContext:[self getMainContext]];
context.undoManager = nil;
//context.automaticallyMergesChangesFromParent = YES;
return context;
}
/**
Commit changes and perform save operation on @c context.
@param flag If @c YES save any parent context as well (recursive).
*/
+ (void)saveContext:(NSManagedObjectContext*)context andParent:(BOOL)flag {
// Performs the save action for the application, which is to send the save: message to the application's managed object context. Any encountered errors are presented to the user.
if (![context commitEditing]) {
NSLog(@"%@:%@ unable to commit editing before saving", [self class], NSStringFromSelector(_cmd));
}
NSError *error = nil;
if (context.hasChanges && ![context save:&error]) {
// Customize this code block to include application-specific recovery steps.
[[NSApplication sharedApplication] presentError:error];
}
if (flag && context.parentContext) {
[self saveContext:context.parentContext andParent:flag];
}
}
#pragma mark - Feed Update
/// @return @c NSDate of next (earliest) feed update. May be @c nil.
+ (NSDate*)nextScheduledUpdate {
NSFetchRequest *fr = [FeedMeta fetchRequest];
[fr addFunctionExpression:@"min:" onKeyPath:@"scheduled" name:@"minDate" type:NSDateAttributeType];
return [fr fetchAllRows: [self getMainContext]].firstObject[@"minDate"];
}
/**
List of @c Feed items that need to be updated. Scheduled time is now (or in past).
@param forceAll If @c YES get a list of all @c Feed regardless of schedules time.
*/
+ (NSArray<Feed*>*)getListOfFeedsThatNeedUpdate:(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]];
}
return [fr fetchAllRows:moc];
}
#pragma mark - Count Elements
/// @return Sum of all unread @c FeedArticle items.
+ (NSUInteger)countTotalUnread {
return [[[FeedArticle fetchRequest] where:@"unread = YES"] fetchCount: [self getMainContext]];
}
/// @return Count of objects at root level. Aka @c sortIndex for the next @c FeedGroup item.
+ (NSUInteger)countRootItemsInContext:(NSManagedObjectContext*)moc {
return [[[FeedGroup fetchRequest] where:@"parent = NULL"] fetchCount:moc];
}
/// @return Unread and total count grouped by @c Feed item.
+ (NSArray<NSDictionary*>*)countAggregatedUnread {
NSFetchRequest *fr = [Feed fetchRequest];
fr.propertiesToGroupBy = @[ @"indexPath" ];
fr.propertiesToFetch = @[ @"indexPath" ];
[fr addFunctionExpression:@"sum:" onKeyPath:@"articles.unread" name:@"unread" type:NSInteger32AttributeType];
[fr addFunctionExpression:@"count:" onKeyPath:@"articles.unread" name:@"total" type:NSInteger32AttributeType];
return [fr fetchAllRows: [self getMainContext]];
}
#pragma mark - Get List Of Elements
/// @return Sorted list of @c FeedGroup items where @c FeedGroup.parent @c = @c parent.
+ (NSArray<FeedGroup*>*)sortedFeedGroupsWithParent:(id)parent inContext:(NSManagedObjectContext*)moc {
return [[[[FeedGroup fetchRequest] where:@"parent = %@", parent] sortASC:@"sortIndex"] fetchAllRows:moc];
}
/// @return Sorted list of @c FeedArticle items where @c FeedArticle.feed @c = @c parent.
+ (NSArray<FeedArticle*>*)sortedArticlesWithParent:(id)parent inContext:(NSManagedObjectContext*)moc {
return [[[[FeedArticle fetchRequest] where:@"feed = %@", parent] sortDESC:@"sortIndex"] fetchAllRows:moc];
}
/// @return Unsorted list of @c Feed items where @c articles.count @c == @c 0.
+ (NSArray<Feed*>*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc {
return [[[Feed fetchRequest] where:@"articles.@count == 0"] fetchAllRows:moc];
}
/// @return Unsorted list of @c Feed items where @c icon is @c nil.
+ (NSArray<Feed*>*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc {
return [[[Feed fetchRequest] where:@"icon = NULL"] fetchAllRows:moc];
}
/// @return Single @c Feed item where @c Feed.indexPath @c = @c path.
+ (Feed*)feedWithIndexPath:(nonnull NSString*)path inContext:(NSManagedObjectContext*)moc {
return [[[Feed fetchRequest] where:@"indexPath = %@", path] fetchFirst:moc];
}
/// @return URL of @c Feed item where @c Feed.indexPath @c = @c path.
+ (NSString*)urlForFeedWithIndexPath:(nonnull NSString*)path {
return [[[[Feed fetchRequest] where:@"indexPath = %@", path] select:@[@"link"]] fetchFirst: [self getMainContext]][@"link"];
}
/// @return Unsorted list of object IDs where @c Feed.indexPath begins with @c path @c + @c "."
+ (NSArray<NSManagedObjectID*>*)feedIDsForIndexPath:(nonnull NSString*)path inContext:(NSManagedObjectContext*)moc {
return [[[Feed fetchRequest] where:@"indexPath BEGINSWITH %@", [path stringByAppendingString:@"."]] fetchIDs:moc];
}
#pragma mark - Unread Articles List & Mark Read
/// @return Return predicate that will match either exactly one, @b or a list of, @b or all @c Feed items.
+ (nullable NSPredicate*)predicateWithPath:(nullable NSString*)path isFeed:(BOOL)flag inContext:(NSManagedObjectContext*)moc {
if (!path) return nil; // match all
if (flag) {
Feed *obj = [self feedWithIndexPath:path inContext:moc];
return [NSPredicate predicateWithFormat:@"feed = %@", obj.objectID];
}
NSArray *list = [self feedIDsForIndexPath:path inContext:moc];
if (list && list.count > 0) {
return [NSPredicate predicateWithFormat:@"feed IN %@", list];
}
return [NSPredicate predicateWithValue:NO]; // match none
}
/**
Return object list with @c FeedArticle where @c unread @c = @c YES. In the same order the user provided.
@param path Match @c Feed items where @c indexPath string matches @c path.
@param feedFlag If @c YES path must match exactly. If @c NO match items that begin with @c path + @c "."
@param sortFlag Whether articles should be returned in sorted order (e.g., for 'open all unread').
@param readFlag Match @c FeedArticle where @c unread @c = @c readFlag.
@param limit Only return first @c X articles that match the criteria.
@return Sorted list of @c FeedArticle with @c unread @c = @c YES.
*/
+ (NSArray<FeedArticle*>*)articlesAtPath:(nullable NSString*)path isFeed:(BOOL)feedFlag sorted:(BOOL)sortFlag unread:(BOOL)readFlag inContext:(NSManagedObjectContext*)moc limit:(NSUInteger)limit {
NSFetchRequest<FeedArticle*> *fr = [[FeedArticle fetchRequest] where:@"unread = %d", readFlag];
fr.fetchLimit = limit;
if (sortFlag) {
if (!path || !feedFlag)
[fr sortASC:@"feed.indexPath"];
[fr sortDESC:@"sortIndex"];
}
/* UNUSED. Batch updates will break NSUndoManager in preferences. Fix that before usage.
NSBatchUpdateRequest *bur = [NSBatchUpdateRequest batchUpdateRequestWithEntityName: FeedArticle.entity.name];
bur.propertiesToUpdate = @{ @"unread": @(!readFlag) };
bur.resultType = NSUpdatedObjectIDsResultType;
bur.predicate = [NSPredicate predicateWithFormat:@"unread = %d", readFlag];*/
NSPredicate *feedFilter = [self predicateWithPath:path isFeed:feedFlag inContext:moc];
if (feedFilter)
fr.predicate = [NSCompoundPredicate andPredicateWithSubpredicates:@[fr.predicate, feedFilter]];
return [fr fetchAllRows:moc];
}
#pragma mark - Restore Sound State
/// Iterate over all @c Feed and re-calculate @c indexPath.
+ (void)restoreFeedIndexPaths {
NSManagedObjectContext *moc = [self getMainContext];
for (Feed *f in [[Feed fetchRequest] fetchAllRows:moc]) {
[f calculateAndSetIndexPathString];
}
[self saveContext:moc andParent:YES];
[moc reset];
}
/**
Delete all @c Feed items where @c group @c = @c NULL and all @c FeedMeta, @c FeedIcon, @c FeedArticle where @c feed @c = @c NULL.
*/
+ (NSUInteger)deleteUnreferenced {
NSUInteger deleted = 0;
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];
[moc reset];
}
return deleted;
}
/// Delete all @c FeedGroup items.
+ (NSUInteger)deleteAllGroups {
NSManagedObjectContext *moc = [self getMainContext];
NSUInteger deleted = [self batchDelete:FeedGroup.entity nullAttribute:nil inContext:moc];
[self saveContext:moc andParent:YES];
[moc reset];
return deleted;
}
/**
Perform batch delete on entities of type @c entity where @c column @c IS @c NULL. If @c column is @c nil, delete all rows.
*/
+ (NSUInteger)batchDelete:(NSEntityDescription*)entity nullAttribute:(NSString*)column inContext:(NSManagedObjectContext*)moc {
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: entity.name];
if (column && column.length > 0) {
// using @count here to also find items where foreign key is set but referencing a non-existing object.
fr.predicate = [NSPredicate predicateWithFormat:@"count(%K) == 0", column];
}
NSBatchDeleteRequest *bdr = [[NSBatchDeleteRequest alloc] initWithFetchRequest:fr];
bdr.resultType = NSBatchDeleteResultTypeCount;
NSError *err;
NSBatchDeleteResult *res = [moc executeRequest:bdr error:&err];
if (err) NSLog(@"%@", err);
return [res.result unsignedIntegerValue];
}
@end

View File

@@ -5,7 +5,6 @@
<attribute name="link" optional="YES" attributeType="String" syncable="YES"/> <attribute name="link" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="subtitle" optional="YES" attributeType="String" syncable="YES"/> <attribute name="subtitle" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="title" optional="YES" attributeType="String" syncable="YES"/> <attribute name="title" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="unreadCount" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<relationship name="articles" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="FeedArticle" inverseName="feed" inverseEntity="FeedArticle" syncable="YES"/> <relationship name="articles" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="FeedArticle" inverseName="feed" inverseEntity="FeedArticle" syncable="YES"/>
<relationship name="group" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="FeedGroup" inverseName="feed" inverseEntity="FeedGroup" syncable="YES"/> <relationship name="group" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="FeedGroup" inverseName="feed" inverseEntity="FeedGroup" syncable="YES"/>
<relationship name="icon" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="FeedIcon" inverseName="feed" inverseEntity="FeedIcon" syncable="YES"/> <relationship name="icon" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="FeedIcon" inverseName="feed" inverseEntity="FeedIcon" syncable="YES"/>
@@ -45,7 +44,7 @@
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="meta" inverseEntity="Feed" syncable="YES"/> <relationship name="feed" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="meta" inverseEntity="Feed" syncable="YES"/>
</entity> </entity>
<elements> <elements>
<element name="Feed" positionX="-278.84765625" positionY="-112.953125" width="128" height="180"/> <element name="Feed" positionX="-278.84765625" positionY="-112.953125" width="128" height="165"/>
<element name="FeedArticle" positionX="-96.77734375" positionY="-113.83984375" width="128" height="195"/> <element name="FeedArticle" positionX="-96.77734375" positionY="-113.83984375" width="128" height="195"/>
<element name="FeedGroup" positionX="-460.37890625" positionY="-111.62890625" width="130.52734375" height="135"/> <element name="FeedGroup" positionX="-460.37890625" positionY="-111.62890625" width="130.52734375" height="135"/>
<element name="FeedIcon" positionX="-202.79296875" positionY="137.71875" width="128" height="75"/> <element name="FeedIcon" positionX="-202.79296875" positionY="137.71875" width="128" height="75"/>

View File

@@ -301,7 +301,7 @@ static BOOL _nextUpdateIsForced = NO;
} else { } else {
success = YES; success = YES;
[f.meta setSucessfulWithResponse:response]; [f.meta setSucessfulWithResponse:response];
if (rss) { if (rss && rss.articles.count > 0) {
[f updateWithRSS:rss postUnreadCountChange:YES]; [f updateWithRSS:rss postUnreadCountChange:YES];
needsNotification = YES; needsNotification = YES;
} }

View File

@@ -1,5 +1,25 @@
//
// 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 <Foundation/Foundation.h>
#import <Cocoa/Cocoa.h> #import <Cocoa/Cocoa.h>
typedef int32_t Interval; typedef int32_t Interval;
@@ -14,11 +34,12 @@ typedef NS_ENUM(int32_t, TimeUnitType) {
@interface NSDate (Ext) @interface NSDate (Ext)
+ (nonnull NSString*)stringForInterval:(Interval)intv rounded:(BOOL)flag; + (nonnull NSString*)stringForInterval:(Interval)intv rounded:(BOOL)flag;
+ (TimeUnitType)unitForInterval:(Interval)intv rounded:(BOOL)flag;
@end @end
@interface NSDate (RefreshControlsUI) @interface NSDate (RefreshControlsUI)
+ (Interval)intervalForPopup:(NSPopUpButton*)unit andField:(NSTextField*)value; + (Interval)intervalForPopup:(NSPopUpButton*)unit andField:(NSTextField*)value;
+ (void)setInterval:(Interval)intv forPopup:(NSPopUpButton*)popup andField:(NSTextField*)field; + (void)setInterval:(Interval)intv forPopup:(NSPopUpButton*)popup andField:(NSTextField*)field animate:(BOOL)flag;
+ (void)populateUnitsMenu:(NSPopUpButton*)popup selected:(TimeUnitType)unit; + (void)populateUnitsMenu:(NSPopUpButton*)popup selected:(TimeUnitType)unit;
@end @end

View File

@@ -1,6 +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 "NSDate+Ext.h" #import "NSDate+Ext.h"
#import <QuartzCore/QuartzCore.h>
static const char _shortnames[] = {'y','w','d','h','m','s'}; static const char _shortnames[] = {'y','w','d','h','m','s'};
static const char *_names[] = {"Years", "Weeks", "Days", "Hours", "Minutes", "Seconds"}; static const char *_names[] = {"Years", "Weeks", "Days", "Hours", "Minutes", "Seconds"};
static const TimeUnitType _values[] = { static const TimeUnitType _values[] = {
@@ -15,6 +38,7 @@ static const TimeUnitType _values[] = {
@implementation NSDate (Ext) @implementation NSDate (Ext)
/// If @c flag @c = @c YES, print @c 1.1f float string with single char unit: e.g., 3.3m, 1.7h.
+ (nonnull NSString*)stringForInterval:(Interval)intv rounded:(BOOL)flag { + (nonnull NSString*)stringForInterval:(Interval)intv rounded:(BOOL)flag {
if (flag) { if (flag) {
unsigned short i = [self floatUnitIndexForInterval:abs(intv)]; unsigned short i = [self floatUnitIndexForInterval:abs(intv)];
@@ -80,10 +104,13 @@ static const TimeUnitType _values[] = {
} }
/// Configure both @c NSControl elements based on the provided interval @c intv. /// Configure both @c NSControl elements based on the provided interval @c intv.
+ (void)setInterval:(Interval)intv forPopup:(NSPopUpButton*)popup andField:(NSTextField*)field { + (void)setInterval:(Interval)intv forPopup:(NSPopUpButton*)popup andField:(NSTextField*)field animate:(BOOL)flag {
TimeUnitType unit = [self unitForInterval:intv rounded:NO]; TimeUnitType unit = [self unitForInterval:intv rounded:NO];
int num = (int)(intv / unit);
if (flag && popup.selectedTag != unit) [self animateControlSize:popup];
if (flag && field.intValue != num) [self animateControlSize:field];
[popup selectItemWithTag:unit]; [popup selectItemWithTag:unit];
field.intValue = (int)(intv / unit); field.intValue = num;
} }
/// Insert all @c TimeUnitType items into popup button. Save unit value into @c tag attribute. /// Insert all @c TimeUnitType items into popup button. Save unit value into @c tag attribute.
@@ -98,4 +125,17 @@ static const TimeUnitType _values[] = {
[popup selectItemWithTag:unit]; [popup selectItemWithTag:unit];
} }
/// Helper method to animate @c NSControl to draw user attention. View will be scalled up in a fraction of a second.
+ (void)animateControlSize:(NSView*)control {
CABasicAnimation *scale = [CABasicAnimation animationWithKeyPath:@"transform"];
CATransform3D tr = CATransform3DIdentity;
tr = CATransform3DTranslate(tr, NSMidX(control.bounds), NSMidY(control.bounds), 0);
tr = CATransform3DScale(tr, 1.1, 1.1, 1);
tr = CATransform3DTranslate(tr, -NSMidX(control.bounds), -NSMidY(control.bounds), 0);
scale.toValue = [NSValue valueWithCATransform3D:tr];
scale.duration = 0.15f;
scale.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseIn];
[control.layer addAnimation:scale forKey:scale.keyPath];
}
@end @end

View File

@@ -21,6 +21,7 @@
// SOFTWARE. // SOFTWARE.
#import "Statistics.h" #import "Statistics.h"
#import "NSDate+Ext.h"
@implementation Statistics @implementation Statistics
@@ -51,56 +52,24 @@
if (differences.count == 0) if (differences.count == 0)
return nil; return nil;
[differences sortUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"intValue" ascending:YES]]]; [differences sortUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"integerValue" ascending:YES]]];
NSUInteger i = differences.count; NSUInteger i = (differences.count/2);
NSUInteger mid = (i/2); NSNumber *median = differences[i];
unsigned int med = differences[mid].unsignedIntValue; if ((differences.count % 2) == 0) { // even feed count, use median of two values
if (i > 1 && (i % 1) == 0) { // even feed count, use median of two values median = [NSNumber numberWithInteger:(median.integerValue + differences[i-1].integerValue) / 2];
med = (med + differences[mid+1].unsignedIntValue) / 2;
} }
return @{@"min" : [self stringForInterval:differences.firstObject.unsignedIntValue], return @{@"min" : differences.firstObject,
@"max" : [self stringForInterval:differences.lastObject.unsignedIntValue], @"max" : differences.lastObject,
@"avg" : [self stringForInterval:[(NSNumber*)[differences valueForKeyPath:@"@avg.self"] unsignedIntValue]], @"avg" : [differences valueForKeyPath:@"@avg.self"],
@"median" : [self stringForInterval:med], @"median" : median,
@"earliest" : earliest, @"earliest" : earliest,
@"latest" : latest }; @"latest" : latest };
} }
/// Print @c 1.1f float string with single char unit: e.g., 3.3m, 1.7h
+ (NSString*)stringForInterval:(unsigned int)val {
float i;
NSUInteger u = [self findAppropriateTimeUnit:val interval:&i];
return [NSString stringWithFormat:@"%1.1f%c", i, [@"smhdw" characterAtIndex:u]];
}
/// @return Unit as int @c (0-4) (0: seconds - 4: weeks). Sets division result @c intv.
+ (NSUInteger)findAppropriateTimeUnit:(unsigned int)val interval:(float*)intv {
if (val > 604800) {*intv = (val / 604800.f); return 4;} // weeks
if (val > 86400) {*intv = (val / 86400.f); return 3;} // days
if (val > 3600) {*intv = (val / 3600.f); return 2;} // hours
if (val > 60) {*intv = (val / 60.f); return 1;} // minutes
*intv = (val / 1.f);
return 0;
}
/// @return Single integer value that combines refresh interval and refresh unit. To be used as @c NSButton.tag
+ (NSInteger)buttonTagFromRefreshString:(NSString*)str {
NSInteger refresh = (NSInteger)roundf([str floatValue]) << 3;
switch ([str characterAtIndex:(str.length - 1)]) {
case 's': return 0 | refresh;
case 'm': return 1 | refresh;
case 'h': return 2 | refresh;
case 'd': return 3 | refresh;
case 'w': return 4 | refresh;
}
return 0; // error, should never happen though
}
#pragma mark - Feed Statistics UI #pragma mark - Feed Statistics UI
/** /**
Generate UI with buttons for min, max, avg and median. Also show number of articles and latest article date. Generate UI with buttons for min, max, avg and median. Also show number of articles and latest article date.
@@ -120,8 +89,7 @@
NSPoint origin = NSZeroPoint; NSPoint origin = NSZeroPoint;
for (NSString *str in @[@"min", @"max", @"avg", @"median"]) { for (NSString *str in @[@"min", @"max", @"avg", @"median"]) {
NSString *title = [str stringByAppendingString:@":"]; NSString *title = [str stringByAppendingString:@":"];
NSString *value = [info valueForKey:str]; NSView *v = [self viewWithLabel:title andInterval:info[str] callback:callback];
NSView *v = [self viewWithLabel:title andRefreshButton:value callback:callback];
[v setFrameOrigin:origin]; [v setFrameOrigin:origin];
[buttonsView addSubview:v]; [buttonsView addSubview:v];
origin.x += NSWidth(v.frame); origin.x += NSWidth(v.frame);
@@ -161,11 +129,8 @@
/** /**
Create view with duration button, e.g., '3.4h' and label infornt of it. Create view with duration button, e.g., '3.4h' and label infornt of it.
*/ */
+ (NSView*)viewWithLabel:(NSString*)title andRefreshButton:(NSString*)value callback:(nullable id<RefreshIntervalButtonDelegate>)callback { + (NSView*)viewWithLabel:(NSString*)title andInterval:(NSNumber*)value callback:(nullable id<RefreshIntervalButtonDelegate>)callback {
static const int buttonPadding = 5; static const int buttonPadding = 5;
if (!value || value.length == 0)
return nil;
NSButton *button = [self grayInlineButton:value]; NSButton *button = [self grayInlineButton:value];
if (callback) { if (callback) {
button.target = callback; button.target = callback;
@@ -194,12 +159,13 @@
/** /**
@return Rounded, gray inline button with tag equal to refresh interval. @return Rounded, gray inline button with tag equal to refresh interval.
*/ */
+ (NSButton*)grayInlineButton:(NSString*)text { + (NSButton*)grayInlineButton:(NSNumber*)num {
NSButton *button = [NSButton buttonWithTitle:text target:nil action:nil]; NSButton *button = [NSButton buttonWithTitle:[NSDate stringForInterval:num.intValue rounded:YES] target:nil action:nil];
button.font = [NSFont monospacedDigitSystemFontOfSize: NSFont.labelFontSize weight:NSFontWeightBold]; button.font = [NSFont monospacedDigitSystemFontOfSize: NSFont.labelFontSize weight:NSFontWeightBold];
button.bezelStyle = NSBezelStyleInline; button.bezelStyle = NSBezelStyleInline;
button.controlSize = NSControlSizeSmall; button.controlSize = NSControlSizeSmall;
button.tag = [self buttonTagFromRefreshString:text]; TimeUnitType unit = [NSDate unitForInterval:num.intValue rounded:YES];
button.tag = (NSInteger)(roundf(num.floatValue / unit) * unit); // rounded inteval
[button sizeToFit]; [button sizeToFit];
return button; return button;
} }

View File

@@ -29,8 +29,6 @@
#import "Statistics.h" #import "Statistics.h"
#import "NSDate+Ext.h" #import "NSDate+Ext.h"
#import <QuartzCore/QuartzCore.h>
#pragma mark - ModalEditDialog - #pragma mark - ModalEditDialog -
@@ -106,7 +104,7 @@
self.url.objectValue = fg.feed.meta.url; self.url.objectValue = fg.feed.meta.url;
self.previousURL = self.url.stringValue; self.previousURL = self.url.stringValue;
self.warningIndicator.image = [fg.feed iconImage16]; self.warningIndicator.image = [fg.feed iconImage16];
[NSDate setInterval:fg.feed.meta.refresh forPopup:self.refreshUnit andField:self.refreshNum]; [NSDate setInterval:fg.feed.meta.refresh forPopup:self.refreshUnit andField:self.refreshNum animate:NO];
[self statsForCoreDataObject]; [self statsForCoreDataObject];
} }
@@ -288,29 +286,7 @@
/// Callback method for @c Statistics @c +viewForRefreshInterval:articleCount:callback: /// Callback method for @c Statistics @c +viewForRefreshInterval:articleCount:callback:
- (void)refreshIntervalButtonClicked:(NSButton *)sender { - (void)refreshIntervalButtonClicked:(NSButton *)sender {
NSInteger num = (sender.tag >> 3); [NSDate setInterval:(Interval)sender.tag forPopup:self.refreshUnit andField:self.refreshNum animate:YES];
NSInteger unit = (sender.tag & 0x7);
if (self.refreshNum.integerValue != num) {
[self animateControlAttention:self.refreshNum];
self.refreshNum.integerValue = num;
}
if (self.refreshUnit.indexOfSelectedItem != unit) {
[self animateControlAttention:self.refreshUnit];
[self.refreshUnit selectItemAtIndex:unit];
}
}
/// Helper method to animate @c NSControl to draw user attention. View will be scalled up in a fraction of a second.
- (void)animateControlAttention:(NSView*)control {
CABasicAnimation *scale = [CABasicAnimation animationWithKeyPath:@"transform"];
CATransform3D tr = CATransform3DIdentity;
tr = CATransform3DTranslate(tr, NSMidX(control.bounds), NSMidY(control.bounds), 0);
tr = CATransform3DScale(tr, 1.1, 1.1, 1);
tr = CATransform3DTranslate(tr, -NSMidX(control.bounds), -NSMidY(control.bounds), 0);
scale.toValue = [NSValue valueWithCATransform3D:tr];
scale.duration = 0.15f;
scale.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseIn];
[control.layer addAnimation:scale forKey:scale.keyPath];
} }

View File

@@ -64,7 +64,7 @@
[sp beginSheetModalForWindow:window completionHandler:^(NSModalResponse result) { [sp beginSheetModalForWindow:window completionHandler:^(NSModalResponse result) {
if (result == NSModalResponseOK) { if (result == NSModalResponseOK) {
BOOL flattened = ([self radioGroupSelection:radioView] == 1); BOOL flattened = ([self radioGroupSelection:radioView] == 1);
NSArray<FeedGroup*> *list = [StoreCoordinator sortedListOfRootObjectsInContext:moc]; NSArray<FeedGroup*> *list = [StoreCoordinator sortedFeedGroupsWithParent:nil inContext:moc];
NSXMLDocument *doc = [self xmlDocumentForFeeds:list hierarchical:!flattened]; NSXMLDocument *doc = [self xmlDocumentForFeeds:list hierarchical:!flattened];
NSData *xml = [doc XMLDataWithOptions:NSXMLNodePreserveAttributeOrder | NSXMLNodePrettyPrint]; NSData *xml = [doc XMLDataWithOptions:NSXMLNodePreserveAttributeOrder | NSXMLNodePrettyPrint];
NSError *error; NSError *error;
@@ -115,12 +115,12 @@
int32_t idx = 0; int32_t idx = 0;
if (select == 1) { // overwrite selected if (select == 1) { // overwrite selected
for (FeedGroup *fg in [StoreCoordinator sortedListOfRootObjectsInContext:moc]) { for (FeedGroup *fg in [StoreCoordinator sortedFeedGroupsWithParent:nil inContext:moc]) {
[moc deleteObject:fg]; // Not a batch delete request to support undo [moc deleteObject:fg]; // Not a batch delete request to support undo
} }
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:@(0)]; [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:@0];
} else { } else {
idx = (int32_t)[StoreCoordinator numberRootItemsInContext:moc]; idx = (int32_t)[StoreCoordinator countRootItemsInContext:moc];
} }
NSMutableArray<Feed*> *list = [NSMutableArray array]; NSMutableArray<Feed*> *list = [NSMutableArray array];

View File

@@ -61,8 +61,8 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
self.dataStore.managedObjectContext.undoManager = self.undoManager; self.dataStore.managedObjectContext.undoManager = self.undoManager;
// Register for notifications // Register for notifications
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateIcon:) name:kNotificationFeedUpdated object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedUpdated:) name:kNotificationFeedUpdated object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateIcon:) name:kNotificationFeedIconUpdated object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedUpdated:) name:kNotificationFeedIconUpdated object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateInProgress:) name:kNotificationBackgroundUpdateInProgress object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateInProgress:) name:kNotificationBackgroundUpdateInProgress object:nil];
} }
@@ -132,8 +132,8 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
#pragma mark - Notification callback methods #pragma mark - Notification callback methods
/// Callback method fired when feeds have been updated in the background. /// Callback method fired when feed (or icon) has been updated in the background.
- (void)updateIcon:(NSNotification*)notify { - (void)feedUpdated:(NSNotification*)notify {
NSManagedObjectID *oid = notify.object; NSManagedObjectID *oid = notify.object;
NSManagedObjectContext *moc = self.dataStore.managedObjectContext; NSManagedObjectContext *moc = self.dataStore.managedObjectContext;
Feed *feed = [moc objectRegisteredForID:oid]; Feed *feed = [moc objectRegisteredForID:oid];

View File

@@ -178,6 +178,7 @@
<string key="keyEquivalent" base64-UTF8="YES"> <string key="keyEquivalent" base64-UTF8="YES">
CA CA
</string> </string>
<modifierMask key="keyEquivalentModifierMask" command="YES"/>
</buttonCell> </buttonCell>
<connections> <connections>
<action selector="remove:" target="-2" id="JeR-iq-Gjb"/> <action selector="remove:" target="-2" id="JeR-iq-Gjb"/>

View File

@@ -22,7 +22,7 @@
#import "SettingsGeneral.h" #import "SettingsGeneral.h"
#import "AppHook.h" #import "AppHook.h"
#import "BarMenu.h" #import "BarStatusItem.h"
#import "UserPrefs.h" #import "UserPrefs.h"
#import "StoreCoordinator.h" #import "StoreCoordinator.h"
#import "Constants.h" #import "Constants.h"
@@ -62,13 +62,13 @@
- (IBAction)fixCache:(NSButton *)sender { - (IBAction)fixCache:(NSButton *)sender {
NSUInteger deleted = [StoreCoordinator deleteUnreferenced]; NSUInteger deleted = [StoreCoordinator deleteUnreferenced];
[StoreCoordinator restoreFeedCountsAndIndexPaths]; [StoreCoordinator restoreFeedIndexPaths];
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil]; [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
NSLog(@"Removed %lu unreferenced core data entries.", deleted); NSLog(@"Removed %lu unreferenced core data entries.", deleted);
} }
- (IBAction)changeMenuBarIconSetting:(NSButton*)sender { - (IBAction)changeMenuBarIconSetting:(NSButton*)sender {
[[(AppHook*)NSApp barMenu] updateBarIcon]; [[(AppHook*)NSApp statusItem] updateBarIcon];
} }
- (IBAction)changeHttpApplication:(NSPopUpButton *)sender { - (IBAction)changeHttpApplication:(NSPopUpButton *)sender {

View File

@@ -28,6 +28,7 @@
+ (NSString*)getHttpApplication; + (NSString*)getHttpApplication;
+ (void)setHttpApplication:(NSString*)bundleID; + (void)setHttpApplication:(NSString*)bundleID;
+ (void)openURLsWithPreferredBrowser:(NSArray<NSURL*>*)urls;
+ (NSUInteger)openFewLinksLimit; // Change with: 'defaults write de.relikd.baRSS openFewLinksLimit -int 10' + (NSUInteger)openFewLinksLimit; // Change with: 'defaults write de.relikd.baRSS openFewLinksLimit -int 10'
+ (NSUInteger)shortArticleNamesLimit; // Change with: 'defaults write de.relikd.baRSS shortArticleNamesLimit -int 50' + (NSUInteger)shortArticleNamesLimit; // Change with: 'defaults write de.relikd.baRSS shortArticleNamesLimit -int 50'

View File

@@ -21,6 +21,7 @@
// SOFTWARE. // SOFTWARE.
#import "UserPrefs.h" #import "UserPrefs.h"
#import <Cocoa/Cocoa.h>
@implementation UserPrefs @implementation UserPrefs
@@ -54,6 +55,16 @@
[[NSUserDefaults standardUserDefaults] setObject:bundleID forKey:@"defaultHttpApplication"]; [[NSUserDefaults standardUserDefaults] setObject:bundleID forKey:@"defaultHttpApplication"];
} }
/**
Open web links in default browser or a browser the user selected in the preferences.
@param urls A list of @c NSURL objects that will be opened immediatelly in bulk.
*/
+ (void)openURLsWithPreferredBrowser:(NSArray<NSURL*>*)urls {
if (urls.count == 0) return;
[[NSWorkspace sharedWorkspace] openURLs:urls withAppBundleIdentifier:[self getHttpApplication] options:NSWorkspaceLaunchDefault additionalEventParamDescriptor:nil launchIdentifiers:nil];
}
#pragma mark - Hidden Plist Properties - #pragma mark - Hidden Plist Properties -
/// @return The limit on how many links should be opened at the same time, if user holds the option key. /// @return The limit on how many links should be opened at the same time, if user holds the option key.

View File

@@ -22,6 +22,9 @@
#import <Cocoa/Cocoa.h> #import <Cocoa/Cocoa.h>
@class BarStatusItem;
@interface BarMenu : NSObject <NSMenuDelegate> @interface BarMenu : NSObject <NSMenuDelegate>
- (void)updateBarIcon; - (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithStatusItem:(BarStatusItem*)statusItem NS_DESIGNATED_INITIALIZER;
@end @end

View File

@@ -21,47 +21,31 @@
// SOFTWARE. // SOFTWARE.
#import "BarMenu.h" #import "BarMenu.h"
#import "StoreCoordinator.h"
#import "FeedDownload.h"
#import "DrawImage.h"
#import "Preferences.h"
#import "UserPrefs.h"
#import "NSMenu+Ext.h"
#import "Constants.h" #import "Constants.h"
#import "NSMenu+Ext.h"
#import "BarStatusItem.h"
#import "MapUnreadTotal.h"
#import "StoreCoordinator.h"
#import "Feed+Ext.h" #import "Feed+Ext.h"
#import "FeedGroup+Ext.h" #import "FeedArticle+Ext.h"
@interface BarMenu() @interface BarMenu()
@property (strong) NSStatusItem *barItem; @property (weak) BarStatusItem *statusItem;
@property (strong) Preferences *prefWindow; @property (strong) MapUnreadTotal *unreadMap;
@property (assign, atomic) NSInteger unreadCountTotal;
@property (assign) BOOL coreDataEmpty;
@property (weak) NSMenu *currentOpenMenu;
@property (strong) NSArray<NSManagedObjectID*> *objectIDsForMenu;
@property (strong) NSManagedObjectContext *readContext;
@end @end
@implementation BarMenu @implementation BarMenu
- (instancetype)init { - (instancetype)initWithStatusItem:(BarStatusItem*)statusItem {
self = [super init]; self = [super init];
self.barItem = [NSStatusBar.systemStatusBar statusItemWithLength:NSVariableStatusItemLength]; self.statusItem = statusItem;
self.barItem.highlightMode = YES; // TODO: move unread counts to status item and keep in sync when changing feeds in preferences
self.barItem.menu = [NSMenu menuWithDelegate:self]; self.unreadMap = [[MapUnreadTotal alloc] initWithCoreData: [StoreCoordinator countAggregatedUnread]];
// Unread counter
self.unreadCountTotal = 0;
[self updateBarIcon];
[self asyncReloadUnreadCountAndUpdateBarIcon:nil];
// Register for notifications // Register for notifications
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedUpdated:) name:kNotificationFeedUpdated object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedUpdated:) name:kNotificationFeedUpdated object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedIconUpdated:) name:kNotificationFeedIconUpdated object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedIconUpdated:) name:kNotificationFeedIconUpdated object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(networkChanged:) name:kNotificationNetworkStatusChanged object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(unreadCountChanged:) name:kNotificationTotalUnreadCountChanged object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(asyncReloadUnreadCountAndUpdateBarIcon:) name:kNotificationTotalUnreadCountReset object:nil];
return self; return self;
} }
@@ -69,167 +53,8 @@
[[NSNotificationCenter defaultCenter] removeObserver:self]; [[NSNotificationCenter defaultCenter] removeObserver:self];
} }
#pragma mark - Update Menu Bar Icon
/** #pragma mark - Generate Menu Items
If notification has @c object use this object to set unread count directly.
If @c object is @c nil perform core data fetch on total unread count and update icon.
*/
- (void)asyncReloadUnreadCountAndUpdateBarIcon:(NSNotification*)notify {
if (notify.object) { // set unread count directly
self.unreadCountTotal = [[notify object] integerValue];
[self updateBarIcon];
} else {
dispatch_async(dispatch_get_main_queue(), ^{
self.unreadCountTotal = [StoreCoordinator unreadCountForIndexPathString:nil];
[self updateBarIcon];
});
}
}
/// Update menu bar icon and text according to unread count and user preferences.
- (void)updateBarIcon {
dispatch_async(dispatch_get_main_queue(), ^{
if (self.unreadCountTotal > 0 && [UserPrefs defaultYES:@"globalUnreadCount"]) {
self.barItem.title = [NSString stringWithFormat:@"%ld", self.unreadCountTotal];
} else {
self.barItem.title = @"";
}
BOOL hasNet = [FeedDownload allowNetworkConnection];
if (self.unreadCountTotal > 0 && hasNet && [UserPrefs defaultYES:@"tintMenuBarIcon"]) {
self.barItem.image = [RSSIcon systemBarIcon:16 tint:[NSColor rssOrange] noConnection:!hasNet];
} else {
self.barItem.image = [RSSIcon systemBarIcon:16 tint:nil noConnection:!hasNet];
self.barItem.image.template = YES;
}
});
}
#pragma mark - Notification callback methods
/**
Callback method fired when network conditions change.
@param notify Notification object contains a @c BOOL value indicating the current status.
*/
- (void)networkChanged:(NSNotification*)notify {
BOOL available = [[notify object] boolValue];
[self.barItem.menu itemWithTag:TagUpdateFeed].enabled = available;
[self updateBarIcon];
}
/**
Callback method fired when feeds have been updated and the total unread count needs update.
@param notify Notification object contains the unread count difference to the current count. May be negative.
*/
- (void)unreadCountChanged:(NSNotification*)notify {
self.unreadCountTotal += [[notify object] integerValue];
[self updateBarIcon];
}
/// Callback method fired when feeds have been updated in the background.
- (void)feedUpdated:(NSNotification*)notify {
[self updateFeed:notify.object updateIconOnly:NO];
}
- (void)feedIconUpdated:(NSNotification*)notify {
[self updateFeed:notify.object updateIconOnly:YES];
}
#pragma mark - Rebuild menu after background feed update
/**
Use this method to update a single menu item and all ancestors unread count.
If the menu isn't currently open, nothing will happen.
@param oid @c NSManagedObjectID must be a @c Feed instance object id.
*/
- (void)updateFeed:(NSManagedObjectID*)oid updateIconOnly:(BOOL)flag {
if (self.barItem.menu.numberOfItems > 0) {
// update items only if menu is already open (e.g., during background update)
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
Feed *feed = [moc objectWithID:oid];
if ([feed isKindOfClass:[Feed class]]) {
NSMenu *menu = [self fixUnreadCountForSubmenus:feed];
if (!flag) [self rebuiltFeedArticle:feed inMenu:menu]; // deepest menu level, feed items
}
[moc reset];
}
}
/**
Go through all parent menus and reset the menu title and unread count
@return @c NSMenu containing @c FeedArticle. Will be @c nil if user hasn't open the menu yet.
*/
- (nullable NSMenu*)fixUnreadCountForSubmenus:(Feed*)feed {
NSMenu *menu = self.barItem.menu;
[menu autoEnableMenuHeader:(self.unreadCountTotal > 0)];
for (FeedGroup *parent in [feed.group allParents]) {
NSInteger itemIndex = [menu feedDataOffset] + parent.sortIndex;
NSMenuItem *item = [menu itemAtIndex:itemIndex];
NSInteger unread = [item setTitleAndUnreadCount:parent];
menu = item.submenu;
if (parent == feed.group) {
// Always set icon. Will flip warning icon to default icon if article count changes.
item.image = [feed iconImage16];
item.enabled = (feed.articles.count > 0);
return menu;
}
if (!menu || menu.numberOfItems == 0)
return nil;
if (unread == 0) // if != 0 then 'setTitleAndUnreadCount' was successful (UserPrefs visible)
unread = [menu coreDataUnreadCount];
[menu autoEnableMenuHeader:(unread > 0)]; // of submenu but not articles menu (will be rebuild anyway)
}
return nil;
}
/**
Remove all @c NSMenuItem in menu and generate new ones. items from @c feed.items.
@param feed Corresponding @c Feed to @c NSMenu.
@param menu Deepest menu level which contains only feed items.
*/
- (void)rebuiltFeedArticle:(Feed*)feed inMenu:(NSMenu*)menu {
if (!menu || menu.numberOfItems == 0) // not opened yet
return;
if (self.currentOpenMenu != menu) {
// if the menu isn't open, re-create it dynamically instead
menu.itemArray.firstObject.parentItem.submenu = [menu cleanInstanceCopy];
} else {
[menu removeAllItems];
[self insertDefaultHeaderForAllMenus:menu hasUnread:(feed.unreadCount > 0)];
for (FeedArticle *fa in [feed sortedArticles]) {
NSMenuItem *mi = [menu addItemWithTitle:@"" action:@selector(openFeedURL:) keyEquivalent:@""];
mi.target = self;
[mi setFeedArticle:fa];
}
}
}
#pragma mark - Menu Delegate & Menu Generation
/// @c currentOpenMenu is needed when a background update occurs. In case a feed items menu is open.
- (void)menuWillOpen:(NSMenu *)menu {
self.currentOpenMenu = menu;
}
/// Get rid of everything that is not needed when the system bar menu is closed.
- (void)menuDidClose:(NSMenu*)menu {
self.currentOpenMenu = nil;
if ([menu isMainMenu])
self.barItem.menu = [NSMenu menuWithDelegate:self];
}
/** /**
@note Delegate method not used. Here to prevent weird @c NSMenu behavior. @note Delegate method not used. Here to prevent weird @c NSMenu behavior.
@@ -240,246 +65,110 @@
return NO; return NO;
} }
/// Perform a core data fatch request, store sorted object ids array and return object count. /// Populate menu with items.
- (NSInteger)numberOfItemsInMenu:(NSMenu*)menu { - (void)menuNeedsUpdate:(NSMenu*)menu {
[self prepareContextAndTemporaryObjectIDs:menu];
if (_coreDataEmpty) return 1; // only if main menu empty
return (NSInteger)self.objectIDsForMenu.count;
}
/// Lazy populate system bar menus when needed.
- (BOOL)menu:(NSMenu*)menu updateItem:(NSMenuItem*)item atIndex:(NSInteger)index shouldCancel:(BOOL)shouldCancel {
if (_coreDataEmpty) {
item.title = NSLocalizedString(@"~~~ list empty ~~~", nil);
item.enabled = NO;
[self finalizeMenu:menu object:nil];
return YES;
}
id obj = [self.readContext objectWithID:[self.objectIDsForMenu objectAtIndex:(NSUInteger)index]];
if ([obj isKindOfClass:[FeedGroup class]]) {
[item setFeedGroup:obj];
if ([(FeedGroup*)obj type] == FEED)
[item setTarget:self action:@selector(openFeedURL:)];
} else if ([obj isKindOfClass:[FeedArticle class]]) {
[item setFeedArticle:obj];
[item setTarget:self action:@selector(openFeedURL:)];
}
if (index + 1 == menu.numberOfItems) { // last item of the menu
[self finalizeMenu:menu object:obj];
[self resetContextAndTemporaryObjectIDs];
}
return YES;
}
#pragma mark - Helper
- (void)prepareContextAndTemporaryObjectIDs:(NSMenu*)menu {
NSMenuItem *parent = [menu.supermenu itemAtIndex:[menu.supermenu indexOfItemWithSubmenu:menu]];
self.readContext = [StoreCoordinator createChildContext]; // will be deleted after menu:updateItem:
self.objectIDsForMenu = [StoreCoordinator sortedObjectIDsForParent:parent.representedObject isFeed:[menu isFeedMenu] inContext:self.readContext];
_coreDataEmpty = ([menu isMainMenu] && self.objectIDsForMenu.count == 0); // initial state or no feeds in date store
}
- (void)resetContextAndTemporaryObjectIDs {
self.objectIDsForMenu = nil;
[self.readContext reset];
self.readContext = nil;
}
/**
Add default menu items that are present in each menu as header and disable menu items if necessary
*/
- (void)finalizeMenu:(NSMenu*)menu object:(id)obj {
NSInteger unreadCount = self.unreadCountTotal; // if parent == nil
if ([menu isFeedMenu]) {
unreadCount = ((FeedArticle*)obj).feed.unreadCount;
} else if (![menu isMainMenu]) {
unreadCount = [menu coreDataUnreadCount];
}
[menu replaceSeparatorStringsWithActualSeparator];
[self insertDefaultHeaderForAllMenus:menu hasUnread:(unreadCount > 0)];
if ([menu isMainMenu])
[self insertMainMenuHeader:menu];
}
/**
Insert items 'Open all unread', 'Mark all read' and 'Mark all unread' at index 0.
@param flag If @c NO, 'Open all unread' and 'Mark all read' will be disabled.
*/
- (void)insertDefaultHeaderForAllMenus:(NSMenu*)menu hasUnread:(BOOL)flag {
MenuItemTag scope = [menu scope];
NSMenuItem *item1 = [NSMenuItem itemWithTitle:NSLocalizedString(@"Open all unread", nil)
action:@selector(openAllUnread:) target:self tag:TagOpenAllUnread | scope];
NSMenuItem *item2 = [item1 alternateWithTitle:[NSString stringWithFormat:@"%@ (%lu)",
NSLocalizedString(@"Open a few unread", nil), [UserPrefs openFewLinksLimit]]];
NSMenuItem *item3 = [NSMenuItem itemWithTitle:NSLocalizedString(@"Mark all read", nil)
action:@selector(markAllReadOrUnread:) target:self tag:TagMarkAllRead | scope];
NSMenuItem *item4 = [NSMenuItem itemWithTitle:NSLocalizedString(@"Mark all unread", nil)
action:@selector(markAllReadOrUnread:) target:self tag:TagMarkAllUnread | scope];
item1.enabled = flag;
item2.enabled = flag;
item3.enabled = flag;
// TODO: disable item3 if all items are unread?
[menu insertItem:item1 atIndex:0];
[menu insertItem:item2 atIndex:1];
[menu insertItem:item3 atIndex:2];
[menu insertItem:item4 atIndex:3];
[menu insertItem:[NSMenuItem separatorItem] atIndex:4];
}
/**
Insert default menu items for the main menu only. Like 'Pause Updates', 'Update all feeds', 'Preferences' and 'Quit'.
*/
- (void)insertMainMenuHeader:(NSMenu*)menu {
NSMenuItem *item1 = [NSMenuItem itemWithTitle:@"" action:@selector(pauseUpdates:) target:self tag:TagPauseUpdates];
NSMenuItem *item2 = [NSMenuItem itemWithTitle:NSLocalizedString(@"Update all feeds", nil)
action:@selector(updateAllFeeds:) target:self tag:TagUpdateFeed];
item1.title = ([FeedDownload isPaused] ?
NSLocalizedString(@"Resume Updates", nil) : NSLocalizedString(@"Pause Updates", nil));
if ([UserPrefs defaultYES:@"globalUpdateAll"] == NO)
item2.hidden = YES;
if (![FeedDownload allowNetworkConnection])
item2.enabled = NO;
[menu insertItem:item1 atIndex:0];
[menu insertItem:item2 atIndex:1];
[menu insertItem:[NSMenuItem separatorItem] atIndex:2];
// < feed content >
[menu addItem:[NSMenuItem separatorItem]];
NSMenuItem *prefs = [NSMenuItem itemWithTitle:NSLocalizedString(@"Preferences", nil)
action:@selector(openPreferences) target:self tag:TagPreferences];
prefs.keyEquivalent = @",";
[menu addItem:prefs];
[menu addItemWithTitle:NSLocalizedString(@"Quit", nil) action:@selector(terminate:) keyEquivalent:@"q"];
}
#pragma mark - Menu Actions
/**
Called whenever the user activates the preferences (either through menu click or hotkey)
*/
- (void)openPreferences {
if (!self.prefWindow) {
self.prefWindow = [[Preferences alloc] initWithWindowNibName:@"Preferences"];
self.prefWindow.window.title = [NSString stringWithFormat:@"%@ %@", NSProcessInfo.processInfo.processName,
NSLocalizedString(@"Preferences", nil)];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(preferencesClosed:) name:NSWindowWillCloseNotification object:self.prefWindow.window];
}
[NSApp activateIgnoringOtherApps:YES];
[self.prefWindow showWindow:nil];
}
/**
Callback method after user closes the preferences window.
*/
- (void)preferencesClosed:(id)sender {
[[NSNotificationCenter defaultCenter] removeObserver:self name:NSWindowWillCloseNotification object:self.prefWindow.window];
self.prefWindow = nil;
[FeedDownload scheduleUpdateForUpcomingFeeds];
}
/**
Called when user clicks on 'Pause Updates' in the main menu (only).
*/
- (void)pauseUpdates:(NSMenuItem*)sender {
[FeedDownload setPaused:![FeedDownload isPaused]];
[self updateBarIcon];
}
/**
Called when user clicks on 'Update all feeds' in the main menu (only).
*/
- (void)updateAllFeeds:(NSMenuItem*)sender {
[self asyncReloadUnreadCountAndUpdateBarIcon:nil];
[FeedDownload forceUpdateAllFeeds];
}
/**
Called when user clicks on 'Open all unread' or 'Open a few unread ...' on any scope level.
*/
- (void)openAllUnread:(NSMenuItem*)sender {
NSMutableArray<NSURL*> *urls = [NSMutableArray<NSURL*> array];
__block int maxItemCount = INT_MAX;
if (sender.isAlternate)
maxItemCount = (int)[UserPrefs openFewLinksLimit];
NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
[sender iterateSorted:YES inContext:moc overDescendentFeeds:^(Feed *feed, BOOL *cancel) { if (menu.isFeedMenu) {
for (FeedArticle *fa in [feed sortedArticles]) { // TODO: open oldest articles first? Feed *feed = [StoreCoordinator feedWithIndexPath:menu.titleIndexPath inContext:moc];
if (maxItemCount <= 0) break; [self setArticles:[feed sortedArticles] forMenu:menu];
if (fa.unread && fa.link.length > 0) { } else {
[urls addObject:[NSURL URLWithString:fa.link]]; NSArray<FeedGroup*> *groups = [StoreCoordinator sortedFeedGroupsWithParent:menu.parentItem.representedObject inContext:moc];
fa.unread = NO; if (groups.count == 0) {
feed.unreadCount -= 1; [menu addItemWithTitle:NSLocalizedString(@"~~~ no entries ~~~", nil) action:nil keyEquivalent:@""].enabled = NO;
self.unreadCountTotal -= 1; } else {
maxItemCount -= 1; [self setFeedGroups:groups forMenu:menu];
} }
} }
*cancel = (maxItemCount <= 0);
}];
[self updateBarIcon];
[self openURLsWithPreferredBrowser:urls];
[StoreCoordinator saveContext:moc andParent:YES];
[moc reset]; [moc reset];
} }
/** /// Get rid of everything that is not needed.
Called when user clicks on 'Mark all read' @b or 'Mark all unread' on any scope level. - (void)menuDidClose:(NSMenu*)menu {
*/ [menu cleanup];
- (void)markAllReadOrUnread:(NSMenuItem*)sender {
BOOL markRead = ((sender.tag & TagMaskType) == TagMarkAllRead);
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
[sender iterateSorted:NO inContext:moc overDescendentFeeds:^(Feed *feed, BOOL *cancel) {
self.unreadCountTotal += (markRead ? [feed markAllItemsRead] : [feed markAllItemsUnread]);
}];
[self updateBarIcon];
[StoreCoordinator saveContext:moc andParent:YES];
[moc reset];
} }
/** /// Generate items for @c FeedGroup menu.
Called when user clicks on a single feed item or the feed group. - (void)setFeedGroups:(NSArray<FeedGroup*>*)sortedList forMenu:(NSMenu*)menu {
[menu insertDefaultHeader];
for (FeedGroup *fg in sortedList) {
[menu insertFeedGroupItem:fg].submenu.delegate = self;
}
UnreadTotal *uct = self.unreadMap[menu.titleIndexPath];
[menu setHeaderHasUnread:(uct.unread > 0) hasRead:(uct.unread < uct.total)];
// set unread counts
for (NSMenuItem *item in menu.itemArray) {
if (item.hasSubmenu)
[item setTitleCount:self.unreadMap[item.submenu.titleIndexPath].unread];
}
}
@param sender A menu item containing either a @c FeedArticle or a @c FeedGroup objectID. /// Generate items for @c FeedArticles menu.
- (void)setArticles:(NSArray<FeedArticle*>*)sortedList forMenu:(NSMenu*)menu {
[menu insertDefaultHeader];
for (FeedArticle *fa in sortedList) {
[menu addItem:[fa newMenuItem]];
}
UnreadTotal *uct = self.unreadMap[menu.titleIndexPath];
[menu setHeaderHasUnread:(uct.unread > 0) hasRead:(uct.unread < uct.total)];
}
#pragma mark - Background Update / Rebuild Menu
/**
Fetch @c Feed from core data and find deepest visible @c NSMenuItem.
@warning @c item and @c feed will often mismatch.
*/ */
- (void)openFeedURL:(NSMenuItem*)sender { - (void)updateFeedMenuItem:(NSManagedObjectID*)oid withBlock:(void(^)(Feed *feed, NSMenuItem *item))block {
NSManagedObjectID *oid = sender.representedObject; NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
if (!oid) Feed *feed = [moc objectWithID:oid];
if (![feed isKindOfClass:[Feed class]]) {
[moc reset];
return; return;
NSString *url = nil;
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
id obj = [moc objectWithID:oid];
if ([obj isKindOfClass:[FeedGroup class]]) {
url = ((FeedGroup*)obj).feed.link;
} else if ([obj isKindOfClass:[FeedArticle class]]) {
FeedArticle *fa = obj;
url = fa.link;
if (fa.unread) {
fa.unread = NO;
fa.feed.unreadCount -= 1;
self.unreadCountTotal -= 1;
[self updateBarIcon];
[StoreCoordinator saveContext:moc andParent:YES];
}
} }
NSMenuItem *item = [self.statusItem.mainMenu deepestItemWithPath:feed.indexPath];
if (!item) {
[moc reset];
return;
}
block(feed, item);
[moc reset]; [moc reset];
if (!url || url.length == 0) return;
[self openURLsWithPreferredBrowser:@[[NSURL URLWithString:url]]];
} }
/** /// Callback method fired when feed has been updated in the background.
Open web links in default browser or a browser the user selected in the preferences. - (void)feedUpdated:(NSNotification*)notify {
[self updateFeedMenuItem:notify.object withBlock:^(Feed *feed, NSMenuItem *item) {
// 1. update in-memory unread count
UnreadTotal *updated = [UnreadTotal new];
updated.total = feed.articles.count;
for (FeedArticle *fa in feed.articles) {
if (fa.unread) updated.unread += 1;
}
[self.unreadMap updateAllCounts:updated forPath:feed.indexPath];
// 2. rebuild articles menu if it is open
if (item.submenu.isFeedMenu) { // menu item is visible
item.enabled = (feed.articles.count > 0);
if (item.submenu.numberOfItems > 0) { // replace articles menu
[item.submenu removeAllItems];
[self setArticles:[feed sortedArticles] forMenu:item.submenu];
}
}
// 3. set unread count & enabled header for all parents
NSArray<UnreadTotal*> *itms = [self.unreadMap itemsForPath:item.submenu.titleIndexPath create:NO];
for (UnreadTotal *uct in itms.reverseObjectEnumerator) {
[item.submenu setHeaderHasUnread:(uct.unread > 0) hasRead:(uct.unread < uct.total)];
[item setTitleCount:uct.unread];
item = item.parentItem;
}
}];
}
@param urls A list of @c NSURL objects that will be opened immediatelly in bulk. /// Callback method fired when feed icon has changed.
*/ - (void)feedIconUpdated:(NSNotification*)notify {
- (void)openURLsWithPreferredBrowser:(NSArray<NSURL*>*)urls { [self updateFeedMenuItem:notify.object withBlock:^(Feed *feed, NSMenuItem *item) {
if (urls.count == 0) return; if (item.submenu.isFeedMenu)
[[NSWorkspace sharedWorkspace] openURLs:urls withAppBundleIdentifier:[UserPrefs getHttpApplication] options:NSWorkspaceLaunchDefault additionalEventParamDescriptor:nil launchIdentifiers:nil]; item.image = [feed iconImage16];
}];
} }
@end @end

View File

@@ -0,0 +1,33 @@
//
// 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/Cocoa.h>
@interface BarStatusItem : NSObject
@property (weak, readonly) NSMenu *mainMenu;
- (void)setUnreadCountAbsolute:(NSUInteger)count;
- (void)setUnreadCountRelative:(NSInteger)count;
- (void)asyncReloadUnreadCount;
- (void)updateBarIcon;
@end

View File

@@ -0,0 +1,182 @@
//
// 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 "BarStatusItem.h"
#import "Constants.h"
#import "DrawImage.h"
#import "FeedDownload.h"
#import "StoreCoordinator.h"
#import "UserPrefs.h"
#import "BarMenu.h"
#import "AppHook.h"
@interface BarStatusItem()
@property (strong) BarMenu *barMenu;
@property (strong) NSStatusItem *statusItem;
@property (assign) NSInteger unreadCountTotal;
@property (weak) NSMenuItem *updateAllItem;
@end
@implementation BarStatusItem
- (NSMenu *)mainMenu { return _statusItem.menu; }
- (instancetype)init {
self = [super init];
// Show icon & prefetch unread count
self.statusItem = [NSStatusBar.systemStatusBar statusItemWithLength:NSVariableStatusItemLength];
self.statusItem.highlightMode = YES;
self.unreadCountTotal = 0;
[self updateBarIcon];
[self asyncReloadUnreadCount];
// Add empty menu (will be populated once opened)
self.statusItem.menu = [[NSMenu alloc] initWithTitle:@"M"];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(mainMenuWillOpen) name:NSMenuDidBeginTrackingNotification object:self.statusItem.menu];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(mainMenuDidClose) name:NSMenuDidEndTrackingNotification object:self.statusItem.menu];
// Some icon unread count notification callback methods
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(networkChanged:) name:kNotificationNetworkStatusChanged object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(unreadCountChanged:) name:kNotificationTotalUnreadCountChanged object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(unreadCountReset:) name:kNotificationTotalUnreadCountReset object:nil];
return self;
}
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
#pragma mark - Notification Center Callback Methods
/// Fired when network conditions change.
- (void)networkChanged:(NSNotification*)notify {
BOOL available = [[notify object] boolValue];
self.updateAllItem.enabled = available;
[self updateBarIcon];
}
/// Fired when a single feed has been updated. Object contains relative unread count change.
- (void)unreadCountChanged:(NSNotification*)notify {
[self setUnreadCountRelative:[[notify object] integerValue]];
}
/**
If notification has @c object use this object to set unread count directly.
If @c object is @c nil perform core data fetch on total unread count and update icon.
*/
- (void)unreadCountReset:(NSNotification*)notify {
if (notify.object) // set unread count directly
[self setUnreadCountAbsolute:[[notify object] unsignedIntegerValue]];
else
[self asyncReloadUnreadCount];
}
#pragma mark - Helper
/// Assign total unread count value directly.
- (void)setUnreadCountAbsolute:(NSUInteger)count {
_unreadCountTotal = (NSInteger)count;
[self updateBarIcon];
}
/// Assign new value by adding @c count to total unread count (may be negative).
- (void)setUnreadCountRelative:(NSInteger)count {
_unreadCountTotal += count;
[self updateBarIcon];
}
/// Fetch new total unread count from core data and assign it as new value (dispatch async on main thread).
- (void)asyncReloadUnreadCount {
dispatch_async(dispatch_get_main_queue(), ^{
[self setUnreadCountAbsolute:[StoreCoordinator countTotalUnread]];
});
}
#pragma mark - Update Menu Bar Icon
/// Update menu bar icon and text according to unread count and user preferences.
- (void)updateBarIcon {
dispatch_async(dispatch_get_main_queue(), ^{
if (self.unreadCountTotal > 0 && [UserPrefs defaultYES:@"globalUnreadCount"]) {
self.statusItem.title = [NSString stringWithFormat:@"%ld", self.unreadCountTotal];
} else {
self.statusItem.title = @"";
}
BOOL hasNet = [FeedDownload allowNetworkConnection];
if (self.unreadCountTotal > 0 && hasNet && [UserPrefs defaultYES:@"tintMenuBarIcon"]) {
self.statusItem.image = [RSSIcon systemBarIcon:16 tint:[NSColor rssOrange] noConnection:!hasNet];
} else {
self.statusItem.image = [RSSIcon systemBarIcon:16 tint:nil noConnection:!hasNet];
self.statusItem.image.template = YES;
}
});
}
#pragma mark - Main Menu Handling
- (void)mainMenuWillOpen {
self.barMenu = [[BarMenu alloc] initWithStatusItem:self];
[self insertMainMenuHeader:self.statusItem.menu];
[self.barMenu menuNeedsUpdate:self.statusItem.menu];
// Add main menu items 'Preferences' and 'Quit'.
[self.statusItem.menu addItem:[NSMenuItem separatorItem]];
[self.statusItem.menu addItemWithTitle:NSLocalizedString(@"Preferences", nil) action:@selector(openPreferences) keyEquivalent:@","];
[self.statusItem.menu addItemWithTitle:NSLocalizedString(@"Quit", nil) action:@selector(terminate:) keyEquivalent:@"q"];
}
- (void)mainMenuDidClose {
[self.statusItem.menu removeAllItems];
self.barMenu = nil;
}
- (void)insertMainMenuHeader:(NSMenu*)menu {
// 'Pause Updates' item
NSMenuItem *pause = [menu addItemWithTitle:NSLocalizedString(@"Pause Updates", nil) action:@selector(pauseUpdates) keyEquivalent:@""];
pause.target = self;
if ([FeedDownload isPaused])
pause.title = NSLocalizedString(@"Resume Updates", nil);
// 'Update all feeds' item
if ([UserPrefs defaultYES:@"globalUpdateAll"]) {
NSMenuItem *updateAll = [menu addItemWithTitle:NSLocalizedString(@"Update all feeds", nil) action:@selector(updateAllFeeds) keyEquivalent:@""];
updateAll.target = self;
updateAll.enabled = [FeedDownload allowNetworkConnection];
self.updateAllItem = updateAll;
}
// Separator between main header and default header
[menu addItem:[NSMenuItem separatorItem]];
}
/// Called when user clicks on 'Pause Updates' (main menu only).
- (void)pauseUpdates {
[FeedDownload setPaused:![FeedDownload isPaused]];
[self updateBarIcon];
}
/// Called when user clicks on 'Update all feeds' (main menu only).
- (void)updateAllFeeds {
// [self asyncReloadUnreadCount]; // should not be necessary
[FeedDownload forceUpdateAllFeeds];
}
@end

View File

@@ -0,0 +1,41 @@
//
// 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 <Foundation/Foundation.h>
@interface UnreadTotal : NSObject
@property (nonatomic, assign) NSUInteger unread;
@property (nonatomic, assign) NSUInteger total;
@end
@interface MapUnreadTotal : NSObject
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithCoreData:(NSArray<NSDictionary*>*)data NS_DESIGNATED_INITIALIZER;
- (NSArray<UnreadTotal*>*)itemsForPath:(NSString*)path create:(BOOL)flag;
- (void)updateAllCounts:(UnreadTotal*)updated forPath:(NSString*)path;
// Keyed subscription
- (UnreadTotal*)objectForKeyedSubscript:(NSString*)key;
- (void)setObject:(UnreadTotal*)obj forKeyedSubscript:(NSString*)key;
@end

View File

@@ -0,0 +1,94 @@
//
// 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 "MapUnreadTotal.h"
@interface MapUnreadTotal()
@property (strong) NSMutableDictionary<NSString*, UnreadTotal*> *map;
@end
@implementation MapUnreadTotal
- (NSString *)description { return _map.description; }
- (UnreadTotal*)objectForKeyedSubscript:(NSString*)key { return _map[key]; }
- (void)setObject:(UnreadTotal*)obj forKeyedSubscript:(NSString*)key { _map[key] = obj; }
/// Perform core data fetch and sum unread counts per @c Feed. Aggregate counts that are grouped in @c FeedGroup.
- (instancetype)initWithCoreData:(NSArray<NSDictionary*>*)data {
self = [super init];
if (self) {
UnreadTotal *sum = [UnreadTotal new];
_map = [NSMutableDictionary dictionaryWithCapacity:data.count];
_map[@""] = sum;
for (NSDictionary *d in data) {
NSUInteger u = [d[@"unread"] unsignedIntegerValue];
NSUInteger t = [d[@"total"] unsignedIntegerValue];
sum.unread += u;
sum.total += t;
for (UnreadTotal *uct in [self itemsForPath:d[@"indexPath"] create:YES]) {
uct.unread += u;
uct.total += t;
}
}
}
return self;
}
/// @return All group items and deepest item of @c path. If @c flag @c = @c YES non-existing items will be created.
- (NSArray<UnreadTotal*>*)itemsForPath:(NSString*)path create:(BOOL)flag {
NSMutableArray<UnreadTotal*> *arr = [NSMutableArray array];
NSMutableString *key = [NSMutableString string];
for (NSString *idx in [path componentsSeparatedByString:@"."]) {
if (key.length > 0)
[key appendString:@"."];
[key appendString:idx];
UnreadTotal *a = _map[key];
if (!a) {
if (!flag) continue; // skip item creation if flag = NO
a = [UnreadTotal new];
_map[key] = a;
}
[arr addObject:a];
}
return arr;
}
/// Set new values for item at @c path. Updating all group items as well.
- (void)updateAllCounts:(UnreadTotal*)updated forPath:(NSString*)path {
UnreadTotal *previous = _map[path];
NSUInteger diffU = (updated.unread - previous.unread);
NSUInteger diffT = (updated.total - previous.total);
for (UnreadTotal *uct in [self itemsForPath:path create:NO]) {
uct.unread += diffU;
uct.total += diffT;
}
}
@end
@implementation UnreadTotal
- (NSString *)description { return [NSString stringWithFormat:@"<unread: %lu, total: %lu>", _unread, _total]; }
@end

View File

@@ -21,20 +21,26 @@
// SOFTWARE. // SOFTWARE.
#import <Cocoa/Cocoa.h> #import <Cocoa/Cocoa.h>
#import "NSMenuItem+Ext.h"
@class FeedGroup;
@interface NSMenu (Ext) @interface NSMenu (Ext)
@property (nonnull, copy, readonly) NSString *titleIndexPath;
@property (nullable, readonly) NSMenuItem* parentItem;
@property (readonly) BOOL isMainMenu;
@property (readonly) BOOL isFeedMenu;
// Generator // Generator
+ (instancetype)menuWithDelegate:(id<NSMenuDelegate>)target; - (NSMenuItem*)insertFeedGroupItem:(FeedGroup*)fg;
- (instancetype)submenuWithIndex:(int)index isFeed:(BOOL)flag; - (void)insertDefaultHeader;
- (instancetype)cleanInstanceCopy; // Update menu
// Properties - (void)cleanup;
- (BOOL)isMainMenu; - (void)setHeaderHasUnread:(BOOL)hasUnread hasRead:(BOOL)hasRead;
- (BOOL)isFeedMenu; - (NSMenuItem*)deepestItemWithPath:(nonnull NSString*)path;
- (MenuItemTag)scope; @end
- (NSInteger)feedDataOffset;
- (NSInteger)coreDataUnreadCount;
// Modify menu @interface NSMenuItem (Ext)
- (void)replaceSeparatorStringsWithActualSeparator; - (instancetype)alternateWithTitle:(NSString*)title;
- (void)autoEnableMenuHeader:(BOOL)hasUnread; - (void)setTitleCount:(NSUInteger)count;
@end @end

View File

@@ -22,101 +22,225 @@
#import "NSMenu+Ext.h" #import "NSMenu+Ext.h"
#import "StoreCoordinator.h" #import "StoreCoordinator.h"
#import "UserPrefs.h"
#import "Feed+Ext.h"
#import "FeedGroup+Ext.h"
#import "Constants.h"
typedef NS_ENUM(NSInteger, MenuItemTag) {
/// Used in @c allowDisplayOfHeaderItem: to identify and enable items
TagMarkAllRead = 1,
TagMarkAllUnread = 2,
TagOpenAllUnread = 3,
/// Delimiter item between default header and core data items
TagHeaderDelimiter = 8,
/// Indicator whether unread count is currently shown in menu item title or not
TagTitleCountVisible = 16,
};
@implementation NSMenu (Ext) @implementation NSMenu (Ext)
#pragma mark - Generator -
/// @return New main menu with target delegate.
+ (instancetype)menuWithDelegate:(id<NSMenuDelegate>)target {
NSMenu *menu = [[NSMenu alloc] initWithTitle:@"M"];
menu.autoenablesItems = NO;
menu.delegate = target;
return menu;
}
/// @return New menu with old title and delegate. Index path in title is appended.
- (instancetype)submenuWithIndex:(int)index isFeed:(BOOL)flag {
NSMenu *menu = [NSMenu menuWithDelegate:self.delegate];
menu.title = [NSString stringWithFormat:@"%c%@.%d", (flag ? 'F' : 'G'), self.title, index];
return menu;
}
/// @return New menu with old title and delegate.
- (instancetype)cleanInstanceCopy {
NSMenu *menu = [NSMenu menuWithDelegate:self.delegate];
menu.title = self.title;
return menu;
}
#pragma mark - Properties - #pragma mark - Properties -
/// @return Dot separated list of @c sortIndex of each @c FeedGroup parent. Empty string if main menu.
- (NSString*)titleIndexPath {
if (self.title.length <= 2) return @"";
return [self.title substringFromIndex:2];
}
/// @return The menu item in the super menu. Or @c nil if there is no super menu.
- (NSMenuItem*)parentItem {
if (!self.supermenu) return nil;
//return self.itemArray.firstObject.parentItem; // wont work without items
//return self.supermenu.highlightedItem; // is highlight guaranteed?
return [self.supermenu itemAtIndex:[self.supermenu indexOfItemWithSubmenu:self]];
}
/// @return @c YES if menu is status bar menu. /// @return @c YES if menu is status bar menu.
- (BOOL)isMainMenu { - (BOOL)isMainMenu { return (self.supermenu == nil); }
return [self.title isEqualToString:@"M"];
}
/// @return @c YES if menu contains feed articles only. /// @return @c YES if menu contains feed articles only.
- (BOOL)isFeedMenu { - (BOOL)isFeedMenu { return ([self.title characterAtIndex:0] == 'F'); }
return [self.title characterAtIndex:0] == 'F';
}
/// @return Either @c ScopeGlobal, @c ScopeGroup or @c ScopeFeed.
- (MenuItemTag)scope {
if ([self isFeedMenu]) return ScopeFeed;
if ([self isMainMenu]) return ScopeGlobal;
return ScopeGroup;
}
/// @return Index offset of the first core data feed item (may be separator), skipping default header and main menu header. #pragma mark - Generator -
- (NSInteger)feedDataOffset {
for (NSInteger i = 0; i < self.numberOfItems; i++) { /// Create new @c NSMenuItem with empty submenu and append it to the menu. @return Inserted item.
if ([[[self itemAtIndex:i] representedObject] isKindOfClass:[NSManagedObjectID class]]) - (NSMenuItem*)insertFeedGroupItem:(FeedGroup*)fg {
return i; unichar chr = '-';
NSMenuItem *item = nil;
switch (fg.type) {
case GROUP: item = [fg newMenuItem]; chr = 'G'; break;
case FEED: item = [fg.feed newMenuItem]; chr = 'F'; break;
case SEPARATOR: item = [NSMenuItem separatorItem]; break;
} }
return 0; if (!item.isSeparatorItem) {
NSString *t = [NSString stringWithFormat:@"%c%@.%d", chr, [self.title substringFromIndex:1], fg.sortIndex];
item.submenu = [[NSMenu alloc] initWithTitle:t];
}
[self addItem:item];
return item;
} }
/// Perform core data fetch request and return unread count for all descendent items. /// Insert items 'Open all unread', 'Mark all read' and 'Mark all unread'.
- (NSInteger)coreDataUnreadCount { - (void)insertDefaultHeader {
NSUInteger loc = [self.title rangeOfString:@"."].location; self.autoenablesItems = NO;
NSString *path = nil; NSMenuItem *itm = [self addItemIfAllowed:TagOpenAllUnread title:NSLocalizedString(@"Open all unread", nil)];
if (loc != NSNotFound) if (itm) {
path = [self.title substringFromIndex:loc + 1]; [self addItem:[itm alternateWithTitle:[NSString stringWithFormat:NSLocalizedString(@"Open a few unread (%lu)", nil), [UserPrefs openFewLinksLimit]]]];
return [StoreCoordinator unreadCountForIndexPathString:path]; }
[self addItemIfAllowed:TagMarkAllRead title:NSLocalizedString(@"Mark all read", nil)];
[self addItemIfAllowed:TagMarkAllUnread title:NSLocalizedString(@"Mark all unread", nil)];
if (self.numberOfItems > 0) {
// in case someone has disabled all header items. Else, during articles menu rebuild it will stay on top.
NSMenuItem *sep = [NSMenuItem separatorItem];
sep.tag = TagHeaderDelimiter;
[self addItem:sep];
}
} }
#pragma mark - Modify Menu - #pragma mark - Update Menu
/// Replace this menu with a clean @c NSMenu. Copy old @c title and @c delegate to new menu. @b Won't work without supermenu!
- (void)cleanup {
NSMenu *m = [[NSMenu alloc] initWithTitle:self.title];
m.delegate = self.delegate;
self.parentItem.submenu = m;
}
/// Loop over default header and enable 'OpenAllUnread' and 'TagMarkAllRead' based on unread count. /// Loop over default header and enable 'OpenAllUnread' and 'TagMarkAllRead' based on unread count.
- (void)autoEnableMenuHeader:(BOOL)hasUnread { - (void)setHeaderHasUnread:(BOOL)hasUnread hasRead:(BOOL)hasRead {
for (NSMenuItem *item in self.itemArray) { NSInteger i = [self indexOfItemWithTag:TagHeaderDelimiter] - 1;
if (item.representedObject) for (; i >= 0; i--) {
return; // default menu has no represented object NSMenuItem *item = [self itemAtIndex:i];
switch (item.tag & TagMaskType) { switch (item.tag) {
case TagOpenAllUnread: case TagMarkAllRead: case TagOpenAllUnread: // incl. alternate item
item.enabled = hasUnread; case TagMarkAllRead:
default: break; item.enabled = hasUnread; break;
case TagMarkAllUnread:
item.enabled = hasRead; break;
} }
//[item applyUserSettingsDisplay]; // should not change while menu is open
} }
} }
/// Loop over menu and replace all separator items (text) with actual separator. /**
- (void)replaceSeparatorStringsWithActualSeparator { Iterate over all menu items in @c self.itemArray and find the item where @c submenu.title matches
for (NSInteger i = 0; i < self.numberOfItems; i++) { the first @c sortIndex in @c path. Recursively repeat the process for the items of this submenu and so on.
NSMenuItem *oldItem = [self itemAtIndex:i];
if ([oldItem.title isEqualToString:kSeparatorItemTitle]) { @param path Dot separated list of @c sortIndex. E.g., @c Feed.indexPath.
NSMenuItem *newItem = [NSMenuItem separatorItem]; @return Either @c NSMenuItem that exactly matches @c path or one of the parent @c NSMenuItem if a submenu isn't open.
newItem.representedObject = oldItem.representedObject; */
[self removeItemAtIndex:i]; - (NSMenuItem*)deepestItemWithPath:(nonnull NSString*)path {
[self insertItem:newItem atIndex:i]; NSUInteger loc = [path rangeOfString:@"."].location;
BOOL isLast = (loc == NSNotFound);
NSString *indexStr = (isLast ? path : [path substringToIndex:loc]);
for (NSMenuItem *item in self.itemArray) {
if (item.hasSubmenu && [item.submenu.title hasSuffix:indexStr]) {
if (!isLast && item.submenu.numberOfItems > 0)
return [item.submenu deepestItemWithPath:[path substringFromIndex:loc+1]];
return item;
} }
} }
return nil;
}
#pragma mark - Helper
/// Check user preferences for preferred display style.
- (BOOL)allowDisplayOfHeaderItem:(MenuItemTag)tag {
static const char * A[] = {"", "global", "feed", "group"};
static const char * B[] = {"", "MarkRead", "MarkUnread", "OpenUnread"};
int idx = (self.isMainMenu ? 1 : (self.isFeedMenu ? 2 : 3));
return [UserPrefs defaultYES:[NSString stringWithFormat:@"%s%s", A[idx], B[tag & 3]]]; // first 2 bits
}
/// Check user preferences if item should be displayed in menu. If so, add it to the menu and set callback to @c self.
- (NSMenuItem*)addItemIfAllowed:(MenuItemTag)tag title:(NSString*)title {
if ([self allowDisplayOfHeaderItem:tag]) {
NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:title action:@selector(headerMenuItemCallback:) keyEquivalent:@""];
item.target = [self class];
item.tag = tag;
item.representedObject = self.title;
[self addItem:item];
return item;
}
return nil;
}
/// Prepare @c userInfo dictionary and send @c NSNotification. Callback for every default header menu item.
+ (void)headerMenuItemCallback:(NSMenuItem*)sender {
BOOL openLinks = NO;
NSUInteger limit = 0;
if (sender.tag == TagOpenAllUnread) {
if (sender.isAlternate)
limit = [UserPrefs openFewLinksLimit];
openLinks = YES;
} else if (sender.tag != TagMarkAllRead && sender.tag != TagMarkAllUnread) {
return; // other menu item clicked. abort and return.
}
BOOL markRead = (sender.tag != TagMarkAllUnread);
BOOL isFeedMenu = NO;
NSString *path = sender.representedObject;
if (path.length > 2) {
isFeedMenu = ([path characterAtIndex:0] == 'F');
path = [path substringFromIndex:2];
} else { // main menu
path = nil;
}
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
NSArray<FeedArticle*> *list = [StoreCoordinator articlesAtPath:path isFeed:isFeedMenu sorted:openLinks unread:markRead inContext:moc limit:limit];
NSNumber *countDiff = [NSNumber numberWithUnsignedInteger:list.count];
if (markRead) countDiff = [NSNumber numberWithInteger: -1 * countDiff.integerValue];
NSMutableArray<NSURL*> *urls = [NSMutableArray arrayWithCapacity:list.count];
for (FeedArticle *fa in list) {
fa.unread = !markRead;
if (openLinks && fa.link.length > 0)
[urls addObject:[NSURL URLWithString:fa.link]];
}
[StoreCoordinator saveContext:moc andParent:YES];
[moc reset];
if (openLinks)
[UserPrefs openURLsWithPreferredBrowser:urls];
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged object:countDiff];
}
@end
#pragma mark - NSMenuItem Category
@implementation NSMenuItem (Ext)
/// Create a copy of an existing menu item and set it's option key modifier.
- (instancetype)alternateWithTitle:(NSString*)title {
NSMenuItem *alt = [self copy];
alt.title = title;
alt.keyEquivalentModifierMask = NSEventModifierFlagOption;
if (!alt.hidden) { // hidden will be ignored if alternate is YES
alt.hidden = YES; // force hidden to hide if menu is already open (background update)
alt.alternate = YES;
}
return alt;
}
/// Remove & append new unread count to title
- (void)setTitleCount:(NSUInteger)count {
if (self.tag == TagTitleCountVisible) {
self.tag = 0; // clear mask
NSUInteger loc = [self.title rangeOfString:@" (" options:NSLiteralSearch | NSBackwardsSearch].location;
if (loc != NSNotFound)
self.title = [self.title substringToIndex:loc];
}
if (count > 0 && [UserPrefs defaultYES:(self.submenu.isFeedMenu ? @"feedUnreadCount" : @"groupUnreadCount")]) {
self.tag = TagTitleCountVisible; // apply new mask
self.title = [self.title stringByAppendingFormat:@" (%ld)", count];
}
} }
@end @end

View File

@@ -1,59 +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/Cocoa.h>
static NSString *kSeparatorItemTitle = @"---SEPARATOR---";
/// @c NSMenuItem options that are assigned to the @c tag attribute.
typedef NS_OPTIONS(NSInteger, MenuItemTag) {
/// Item visible at the very first menu level @c (StatusBar)
ScopeGlobal = 2,
/// Item visible at each group, e.g., multiple feeds in one group
ScopeGroup = 4,
/// Item visible at the deepest menu level @c (FeedArticle)
ScopeFeed = 8,
///
TagPreferences = (1 << 4),
TagPauseUpdates = (2 << 4),
TagUpdateFeed = (3 << 4),
TagMarkAllRead = (4 << 4),
TagMarkAllUnread = (5 << 4),
TagOpenAllUnread = (6 << 4),
TagMaskScope = 0xF,
TagMaskType = 0xFFF0,
};
@class FeedGroup, Feed, FeedArticle;
@interface NSMenuItem (Feed)
+ (NSMenuItem*)itemWithTitle:(NSString*)title action:(SEL)selector target:(id)target tag:(MenuItemTag)tag;
- (NSMenuItem*)alternateWithTitle:(NSString*)title;
- (void)setTarget:(id)target action:(SEL)selector;
- (void)setFeedGroup:(FeedGroup*)group;
- (void)setFeedArticle:(FeedArticle*)article;
- (NSInteger)setTitleAndUnreadCount:(FeedGroup*)group;
- (void)iterateSorted:(BOOL)ordered inContext:(NSManagedObjectContext*)moc overDescendentFeeds:(void(^)(Feed *feed, BOOL *cancel))block;
@end

View File

@@ -1,225 +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 "NSMenuItem+Ext.h"
#import "NSMenu+Ext.h"
#import "StoreCoordinator.h"
#import "DrawImage.h"
#import "UserPrefs.h"
#import "Feed+Ext.h"
#import "FeedGroup+Ext.h"
/// User preferences for displaying menu items
typedef NS_ENUM(char, DisplaySetting) {
/// User preference not available. @c NSMenuItem is not configurable (not a header item)
INVALID,
/// User preference to display this item
ALLOW,
/// User preference to hide this item
PROHIBIT
};
@implementation NSMenuItem (Feed)
#pragma mark - General helper methods -
/**
Helper method to generate a new @c NSMenuItem.
*/
+ (NSMenuItem*)itemWithTitle:(NSString*)title action:(SEL)selector target:(id)target tag:(MenuItemTag)tag {
NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:title action:selector keyEquivalent:@""];
item.target = target;
item.tag = tag;
[item applyUserSettingsDisplay];
return item;
}
/**
Create a copy of an existing menu item and set it's option key modifier.
*/
- (NSMenuItem*)alternateWithTitle:(NSString*)title {
NSMenuItem *alt = [self copy];
alt.title = title;
alt.keyEquivalentModifierMask = NSEventModifierFlagOption;
if (!alt.hidden) { // hidden will be ignored if alternate is YES
alt.hidden = YES; // force hidden to hide if menu is already open (background update)
alt.alternate = YES;
}
return alt;
}
/**
Convenient method to set @c target and @c action simultaneously.
*/
- (void)setTarget:(id)target action:(SEL)selector {
self.target = target;
self.action = selector;
}
#pragma mark - Set properties based on Core Data object -
/**
Set title based on preferences either with or without unread count in parenthesis.
@return Number of unread items. (@b warning: May return @c 0 if visibility is disabled in @c UserPrefs)
*/
- (NSInteger)setTitleAndUnreadCount:(FeedGroup*)fg {
NSInteger uCount = 0;
if (fg.type == FEED && [UserPrefs defaultYES:@"feedUnreadCount"]) {
uCount = fg.feed.unreadCount;
} else if (fg.type == GROUP && [UserPrefs defaultYES:@"groupUnreadCount"]) {
uCount = [self.submenu coreDataUnreadCount];
}
NSString *name = (fg.name ? fg.name : NSLocalizedString(@"(error)", nil));
self.title = (uCount == 0 ? name : [NSString stringWithFormat:@"%@ (%ld)", name, uCount]);
return uCount;
}
/**
Fully configures a Separator item OR group item OR feed item. (but not @c FeedArticle item)
*/
- (void)setFeedGroup:(FeedGroup*)fg {
self.representedObject = fg.objectID;
if (fg.type == SEPARATOR) {
self.title = kSeparatorItemTitle;
} else {
self.submenu = [self.menu submenuWithIndex:fg.sortIndex isFeed:(fg.type == FEED)];
[self setTitleAndUnreadCount:fg]; // after submenu is set
if (fg.type == FEED) {
self.tag = ScopeFeed;
self.toolTip = fg.feed.subtitle;
self.enabled = (fg.feed.articles.count > 0);
self.image = [fg.feed iconImage16];
} else {
self.tag = ScopeGroup;
self.enabled = (fg.children.count > 0);
self.image = [fg groupIconImage16];
}
}
}
/**
Populate @c NSMenuItem based on the attributes of a @c FeedArticle.
*/
- (void)setFeedArticle:(FeedArticle*)fa {
self.title = fa.title;
// TODO: It should be enough to get user prefs once per menu build
if ([UserPrefs defaultNO:@"feedShortNames"]) {
NSUInteger limit = [UserPrefs shortArticleNamesLimit];
if (self.title.length > limit)
self.title = [NSString stringWithFormat:@"%@…", [self.title substringToIndex:limit-1]];
}
self.tag = ScopeFeed;
self.enabled = (fa.link.length > 0);
self.state = (fa.unread && [UserPrefs defaultYES:@"feedTickMark"] ? NSControlStateValueOn : NSControlStateValueOff);
self.representedObject = fa.objectID;
//mi.toolTip = item.abstract;
// TODO: Do regex during save, not during display. Its here for testing purposes ...
if (fa.abstract.length > 0) {
NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"<[^>]*>" options:kNilOptions error:nil];
self.toolTip = [regex stringByReplacingMatchesInString:fa.abstract options:kNilOptions range:NSMakeRange(0, fa.abstract.length) withTemplate:@""];
}
}
#pragma mark - Helper -
/**
@return @c FeedGroup object if @c representedObject contains a valid @c NSManagedObjectID.
*/
- (FeedGroup*)requestGroup:(NSManagedObjectContext*)moc {
if (!self.representedObject || ![self.representedObject isKindOfClass:[NSManagedObjectID class]])
return nil;
FeedGroup *fg = [moc objectWithID:self.representedObject];
if (![fg isKindOfClass:[FeedGroup class]])
return nil;
return fg;
}
/**
Perform @c block on every @c FeedGroup in the items menu or any of its submenues.
@param ordered Whether order matters or not. If all items are processed anyway, pass @c NO for a speedup.
@param block Set cancel to @c YES to stop enumeration early.
*/
- (void)iterateSorted:(BOOL)ordered inContext:(NSManagedObjectContext*)moc overDescendentFeeds:(void(^)(Feed*,BOOL*))block {
if (self.parentItem) {
[[self.parentItem requestGroup:moc] iterateSorted:ordered overDescendantFeeds:block];
} else {
for (NSMenuItem *item in self.menu.itemArray) {
FeedGroup *fg = [item requestGroup:moc];
if (fg != nil) { // All groups and feeds; Ignore default header
if (![fg iterateSorted:ordered overDescendantFeeds:block])
return;
}
}
}
}
/**
Check user preferences for preferred display style.
@return As per user settings return @c ALLOW or @c PROHIBIT. Will return @c INVALID for items that aren't configurable.
*/
- (DisplaySetting)allowsDisplay {
NSString *prefix;
switch (self.tag & TagMaskScope) {
case ScopeFeed: prefix = @"feed"; break;
case ScopeGroup: prefix = @"group"; break;
case ScopeGlobal: prefix = @"global"; break;
default: return INVALID; // no scope, not recognized menu item
}
NSString *postfix;
switch (self.tag & TagMaskType) {
case TagOpenAllUnread: postfix = @"OpenUnread"; break;
case TagMarkAllRead: postfix = @"MarkRead"; break;
case TagMarkAllUnread: postfix = @"MarkUnread"; break;
default: return INVALID; // wrong tag, ignore
}
if ([UserPrefs defaultYES:[prefix stringByAppendingString:postfix]])
return ALLOW;
return PROHIBIT;
}
/**
Set item @c hidden based on user preferences. Does nothing for items that aren't configurable in settings.
*/
- (void)applyUserSettingsDisplay {
switch ([self allowsDisplay]) {
case ALLOW:
self.hidden = NO;
if (self.keyEquivalentModifierMask == NSEventModifierFlagOption)
self.alternate = YES; // restore alternate flag
break;
case PROHIBIT:
if (self.isAlternate)
self.alternate = NO; // to allow hidden = YES, alternate flag needs to be NO
self.hidden = YES;
break;
case INVALID: break;
}
}
@end

View File

@@ -1,247 +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 "StoreCoordinator.h"
#import "AppHook.h"
#import "Feed+Ext.h"
@implementation StoreCoordinator
#pragma mark - Managing contexts
/// @return The application main persistent context.
+ (NSManagedObjectContext*)getMainContext {
return [(AppHook*)NSApp persistentContainer].viewContext;
}
/// New child context with @c NSMainQueueConcurrencyType and without undo manager.
+ (NSManagedObjectContext*)createChildContext {
NSManagedObjectContext *context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType];
[context setParentContext:[self getMainContext]];
context.undoManager = nil;
//context.automaticallyMergesChangesFromParent = YES;
return context;
}
/**
Commit changes and perform save operation on @c context.
@param flag If @c YES save any parent context as well (recursive).
*/
+ (void)saveContext:(NSManagedObjectContext*)context andParent:(BOOL)flag {
// Performs the save action for the application, which is to send the save: message to the application's managed object context. Any encountered errors are presented to the user.
if (![context commitEditing]) {
NSLog(@"%@:%@ unable to commit editing before saving", [self class], NSStringFromSelector(_cmd));
}
NSError *error = nil;
if (context.hasChanges && ![context save:&error]) {
// Customize this code block to include application-specific recovery steps.
[[NSApplication sharedApplication] presentError:error];
}
if (flag && context.parentContext) {
[self saveContext:context.parentContext andParent:flag];
}
}
#pragma mark - Helper
/// Perform fetch and return result. If an error occurs, print it to the console.
+ (NSArray*)fetchAllRows:(NSFetchRequest*)req inContext:(NSManagedObjectContext*)moc {
NSError *err;
NSArray *fetchResults = [moc executeFetchRequest:req error:&err];
if (err) NSLog(@"ERROR: Fetch request failed: %@", err);
//NSLog(@"%@ ==> %@", req, fetchResults); // debugging
return fetchResults;
}
/// Perform aggregated fetch where result is a single row. Use convenient methods @c fetchDate: or @c fetchInteger:.
+ (id)fetchSingleRow:(NSManagedObjectContext*)moc request:(NSFetchRequest*)req expression:(NSExpression*)exp resultType:(NSAttributeType)type {
NSExpressionDescription *expDesc = [[NSExpressionDescription alloc] init];
[expDesc setName:@"singleRowAttribute"];
[expDesc setExpression:exp];
[expDesc setExpressionResultType:type];
[req setResultType:NSDictionaryResultType];
[req setPropertiesToFetch:@[expDesc]];
return [self fetchAllRows:req inContext:moc].firstObject[@"singleRowAttribute"];
}
/// Convenient method on @c fetchSingleRow: with @c NSDate return type. May be @c nil.
+ (NSDate*)fetchDate:(NSManagedObjectContext*)moc request:(NSFetchRequest*)req expression:(NSExpression*)exp {
return [self fetchSingleRow:moc request:req expression:exp resultType:NSDateAttributeType]; // can be nil
}
/// Convenient method on @c fetchSingleRow: with @c NSInteger return type.
+ (NSInteger)fetchInteger:(NSManagedObjectContext*)moc request:(NSFetchRequest*)req expression:(NSExpression*)exp {
return [[self fetchSingleRow:moc request:req expression:exp resultType:NSInteger32AttributeType] integerValue];
}
#pragma mark - Feed Update
/**
List of @c Feed items that need to be updated. Scheduled time is now (or in past).
@param forceAll If @c YES get a list of all @c Feed regardless of schedules time.
*/
+ (NSArray<Feed*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc {
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
if (!forceAll) {
// when fetching also get those feeds that would need update soon (now + 10s)
fr.predicate = [NSPredicate predicateWithFormat:@"meta.scheduled <= %@", [NSDate dateWithTimeIntervalSinceNow:+10]];
}
return [self fetchAllRows:fr inContext:moc];
}
/// @return @c NSDate of next (earliest) feed update. May be @c nil.
+ (NSDate*)nextScheduledUpdate {
// Always get context first, or 'FeedMeta.entity.name' may not be available on app start
NSManagedObjectContext *moc = [self getMainContext];
NSExpression *exp = [NSExpression expressionForFunction:@"min:" arguments:@[[NSExpression expressionForKeyPath:@"scheduled"]]];
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedMeta.entity.name];
return [self fetchDate:moc request:fr expression:exp];
}
#pragma mark - Main Menu Display
/**
Perform core data fetch request with sum over all unread feeds matching @c str.
@param str A dot separated string of integer index parts.
*/
+ (NSInteger)unreadCountForIndexPathString:(NSString*)str {
// Always get context first, or 'Feed.entity.name' may not be available on app start
NSManagedObjectContext *moc = [self getMainContext];
NSExpression *exp = [NSExpression expressionForFunction:@"sum:" arguments:@[[NSExpression expressionForKeyPath:@"unreadCount"]]];
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
if (str && str.length > 0)
fr.predicate = [NSPredicate predicateWithFormat:@"indexPath BEGINSWITH %@", [str stringByAppendingString:@"."]];
return [self fetchInteger:moc request:fr expression:exp];
}
/**
Get sorted list of @c ObjectIDs for either @c FeedGroup or @c FeedArticle.
@param parent Either @c ObjectID or actual object. Or @c nil for root folder.
@param flag If @c YES request list of @c FeedArticle instead of @c FeedGroup
*/
+ (NSArray*)sortedObjectIDsForParent:(id)parent isFeed:(BOOL)flag inContext:(NSManagedObjectContext*)moc {
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: (flag ? FeedArticle.entity : FeedGroup.entity).name];
fr.predicate = [NSPredicate predicateWithFormat:(flag ? @"feed.group = %@" : @"parent = %@"), parent];
fr.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:!flag]];
[fr setResultType:NSManagedObjectIDResultType]; // only get ids
return [self fetchAllRows:fr inContext:moc];
}
#pragma mark - OPML Import & Export
/// @return Count of objects at root level. Also the @c sortIndex for the next item.
+ (NSInteger)numberRootItemsInContext:(NSManagedObjectContext*)moc {
NSExpression *exp = [NSExpression expressionForFunction:@"count:" arguments:@[[NSExpression expressionForEvaluatedObject]]];
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedGroup.entity.name];
fr.predicate = [NSPredicate predicateWithFormat:@"parent = NULL"];
return [self fetchInteger:moc request:fr expression:exp];
}
/// @return Sorted list of root element objects.
+ (NSArray<FeedGroup*>*)sortedListOfRootObjectsInContext:(NSManagedObjectContext*)moc {
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: FeedGroup.entity.name];
fr.predicate = [NSPredicate predicateWithFormat:@"parent = NULL"];
fr.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]];
return [self fetchAllRows:fr inContext:moc];
}
#pragma mark - Restore Sound State
/**
Perform batch delete on entities of type @c entity where @c column @c IS @c NULL. If @c column is @c nil, delete all rows.
*/
+ (NSUInteger)batchDelete:(NSEntityDescription*)entity nullAttribute:(NSString*)column inContext:(NSManagedObjectContext*)moc {
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: entity.name];
if (column && column.length > 0) {
// double nested string, otherwise column is not interpreted as such.
// using @count here to also find items where foreign key is set but referencing a non-existing object.
fr.predicate = [NSPredicate predicateWithFormat:[NSString stringWithFormat:@"count(%@) == 0", column]];
}
NSBatchDeleteRequest *bdr = [[NSBatchDeleteRequest alloc] initWithFetchRequest:fr];
bdr.resultType = NSBatchDeleteResultTypeCount;
NSError *err;
NSBatchDeleteResult *lol = [moc executeRequest:bdr error:&err];
if (err) NSLog(@"%@", err);
return [lol.result unsignedIntegerValue];
}
/**
Delete all @c FeedGroup items.
*/
+ (NSUInteger)deleteAllGroups {
NSManagedObjectContext *moc = [self getMainContext];
NSUInteger deleted = [self batchDelete:FeedGroup.entity nullAttribute:nil inContext:moc];
[self saveContext:moc andParent:YES];
return deleted;
}
/**
Delete all @c Feed items where @c group @c = @c NULL and all @c FeedMeta, @c FeedIcon, @c FeedArticle where @c feed @c = @c NULL.
*/
+ (NSUInteger)deleteUnreferenced {
NSUInteger deleted = 0;
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];
[self saveContext:moc andParent:YES];
return deleted;
}
/**
Iterate over all @c Feed and re-calculate @c unreadCount and @c indexPath.
*/
+ (void)restoreFeedCountsAndIndexPaths {
NSManagedObjectContext *moc = [self getMainContext];
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
for (Feed *f in [self fetchAllRows:fr inContext:moc]) {
[f calculateAndSetUnreadCount];
[f calculateAndSetIndexPathString];
}
[self saveContext:moc andParent:YES];
}
/// @return All @c Feed items where @c articles.count @c == @c 0
+ (NSArray<Feed*>*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc {
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
fr.predicate = [NSPredicate predicateWithFormat:@"articles.@count == 0"];
return [self fetchAllRows:fr inContext:moc];
}
/// @return All @c Feed items where @c icon is @c nil.
+ (NSArray<Feed*>*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc {
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: Feed.entity.name];
fr.predicate = [NSPredicate predicateWithFormat:@"icon = NULL"];
return [self fetchAllRows:fr inContext:moc];
}
@end