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