Drag & drop support for OPML files
This commit is contained in:
19
CHANGELOG.md
19
CHANGELOG.md
@@ -7,23 +7,28 @@ and this project does NOT adhere to [Semantic Versioning](https://semver.org/spe
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### Added
|
||||||
- Show users any 5xx server error response and extracted failure reason
|
- Adding feed: 5xx server errors have a reload button which will initiate a new download with the same URL
|
||||||
- 5xx server errors have a reload button which will initiate a new download with the same URL
|
|
||||||
- Adding feed: Cmd+R will reload the same URL
|
- Adding feed: Cmd+R will reload the same URL
|
||||||
- Settings, Feeds: Cmd+R will reload the data source
|
- Settings, Feeds: Cmd+R will reload the data source
|
||||||
- Refresh interval string localizations
|
- Settings, Feeds: Refresh interval string localizations
|
||||||
|
- Settings, Feeds: Right click menu with edit actions
|
||||||
|
- Settings, Feeds: Drag & Drop feeds from / to OPML file
|
||||||
|
- Settings, Feeds: Drag & Drop feed titles and urls as text
|
||||||
|
- Accessibility hints for most UI elements
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Changed error message text when user cancels creation of new feed item
|
- Adding feed: Show users any 5xx server error response and extracted failure reason
|
||||||
- Comparing existing articles with nonexistent guid and link
|
|
||||||
- Adding feed: If URLs can't be resolved in the first run (5xx error), try a second time. E.g., 'Done' click (issue: #5)
|
- Adding feed: If URLs can't be resolved in the first run (5xx error), try a second time. E.g., 'Done' click (issue: #5)
|
||||||
|
- Settings, Feeds: Actions 'delete' and 'edit' use clicked items instead of selected items
|
||||||
|
- Comparison of existing articles with nonexistent guid and link
|
||||||
- Don't mark articles read if opening URLs failed
|
- Don't mark articles read if opening URLs failed
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Interface builder files replaced with code equivalent
|
- Interface builder files replaced with code equivalent
|
||||||
- Settings, Feeds: Single add button for feeds, groups, and separators
|
- Settings, Feeds: Single add button for feeds, groups, and separators
|
||||||
- Refresh interval hotkeys set to: Cmd+1 … Cmd+6
|
- Settings, Feeds: Always append new items at the end
|
||||||
- Always append new items at the end
|
- Adding feed: Display error reason if user cancels the creation of a new feed item
|
||||||
|
- Adding feed: Refresh interval hotkeys set to: Cmd+1 … Cmd+6
|
||||||
|
|
||||||
|
|
||||||
## [0.9.4] - 2019-04-02
|
## [0.9.4] - 2019-04-02
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
54B749E0220636200022CC6D /* FeedArticle+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B749DF220635CD0022CC6D /* FeedArticle+Ext.m */; };
|
54B749E0220636200022CC6D /* FeedArticle+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B749DF220635CD0022CC6D /* FeedArticle+Ext.m */; };
|
||||||
54BB048921FD2AB500C303A5 /* NSDate+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54BB048821FD2AB500C303A5 /* NSDate+Ext.m */; };
|
54BB048921FD2AB500C303A5 /* NSDate+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54BB048821FD2AB500C303A5 /* NSDate+Ext.m */; };
|
||||||
54BF444A22D0F4F300660096 /* AppIcon.icns in Resources */ = {isa = PBXBuildFile; fileRef = 54BF444922D0F4F300660096 /* AppIcon.icns */; };
|
54BF444A22D0F4F300660096 /* AppIcon.icns in Resources */ = {isa = PBXBuildFile; fileRef = 54BF444922D0F4F300660096 /* AppIcon.icns */; };
|
||||||
|
54D55D7322E624CD00057B98 /* SettingsFeeds+DragDrop.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D55D7222E624CD00057B98 /* SettingsFeeds+DragDrop.m */; };
|
||||||
54D857CE227C5785001BA1C8 /* RefreshStatisticsView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D857CD227C5785001BA1C8 /* RefreshStatisticsView.m */; };
|
54D857CE227C5785001BA1C8 /* RefreshStatisticsView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D857CD227C5785001BA1C8 /* RefreshStatisticsView.m */; };
|
||||||
54E88320211B509D00064188 /* ModalFeedEdit.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E8831E211B509D00064188 /* ModalFeedEdit.m */; };
|
54E88320211B509D00064188 /* ModalFeedEdit.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E8831E211B509D00064188 /* ModalFeedEdit.m */; };
|
||||||
54E9CF32225914300023696F /* SettingsAbout.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E9CF31225914300023696F /* SettingsAbout.m */; };
|
54E9CF32225914300023696F /* SettingsAbout.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E9CF31225914300023696F /* SettingsAbout.m */; };
|
||||||
@@ -126,6 +127,8 @@
|
|||||||
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>"; };
|
||||||
54BF444922D0F4F300660096 /* AppIcon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = AppIcon.icns; sourceTree = "<group>"; };
|
54BF444922D0F4F300660096 /* AppIcon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = AppIcon.icns; sourceTree = "<group>"; };
|
||||||
|
54D55D7122E624CD00057B98 /* SettingsFeeds+DragDrop.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SettingsFeeds+DragDrop.h"; sourceTree = "<group>"; };
|
||||||
|
54D55D7222E624CD00057B98 /* SettingsFeeds+DragDrop.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "SettingsFeeds+DragDrop.m"; sourceTree = "<group>"; };
|
||||||
54D857CC227C5785001BA1C8 /* RefreshStatisticsView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RefreshStatisticsView.h; sourceTree = "<group>"; };
|
54D857CC227C5785001BA1C8 /* RefreshStatisticsView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RefreshStatisticsView.h; sourceTree = "<group>"; };
|
||||||
54D857CD227C5785001BA1C8 /* RefreshStatisticsView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RefreshStatisticsView.m; sourceTree = "<group>"; };
|
54D857CD227C5785001BA1C8 /* RefreshStatisticsView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RefreshStatisticsView.m; sourceTree = "<group>"; };
|
||||||
54D857D022802309001BA1C8 /* SettingsGeneralView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SettingsGeneralView.h; sourceTree = "<group>"; };
|
54D857D022802309001BA1C8 /* SettingsGeneralView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SettingsGeneralView.h; sourceTree = "<group>"; };
|
||||||
@@ -301,6 +304,10 @@
|
|||||||
children = (
|
children = (
|
||||||
546FC43B21188AD5007CC3A3 /* SettingsFeeds.h */,
|
546FC43B21188AD5007CC3A3 /* SettingsFeeds.h */,
|
||||||
546FC43C21188AD5007CC3A3 /* SettingsFeeds.m */,
|
546FC43C21188AD5007CC3A3 /* SettingsFeeds.m */,
|
||||||
|
54D55D7122E624CD00057B98 /* SettingsFeeds+DragDrop.h */,
|
||||||
|
54D55D7222E624CD00057B98 /* SettingsFeeds+DragDrop.m */,
|
||||||
|
54F6025B21C1D4170006D338 /* OpmlExport.h */,
|
||||||
|
54F6025C21C1D4170006D338 /* OpmlExport.m */,
|
||||||
5478DF02225A7AE200D30C64 /* SettingsFeedsView.h */,
|
5478DF02225A7AE200D30C64 /* SettingsFeedsView.h */,
|
||||||
5478DF03225A7AE200D30C64 /* SettingsFeedsView.m */,
|
5478DF03225A7AE200D30C64 /* SettingsFeedsView.m */,
|
||||||
54E8831D211B509D00064188 /* ModalFeedEdit.h */,
|
54E8831D211B509D00064188 /* ModalFeedEdit.h */,
|
||||||
@@ -309,8 +316,6 @@
|
|||||||
54B51703226DC339006C1B29 /* ModalFeedEditView.m */,
|
54B51703226DC339006C1B29 /* ModalFeedEditView.m */,
|
||||||
54D857CC227C5785001BA1C8 /* RefreshStatisticsView.h */,
|
54D857CC227C5785001BA1C8 /* RefreshStatisticsView.h */,
|
||||||
54D857CD227C5785001BA1C8 /* RefreshStatisticsView.m */,
|
54D857CD227C5785001BA1C8 /* RefreshStatisticsView.m */,
|
||||||
54F6025B21C1D4170006D338 /* OpmlExport.h */,
|
|
||||||
54F6025C21C1D4170006D338 /* OpmlExport.m */,
|
|
||||||
);
|
);
|
||||||
path = "Feeds Tab";
|
path = "Feeds Tab";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -430,6 +435,7 @@
|
|||||||
546A6A2C22C584AF0034E806 /* SettingsAppearanceView.m in Sources */,
|
546A6A2C22C584AF0034E806 /* SettingsAppearanceView.m in Sources */,
|
||||||
54E9CF32225914300023696F /* SettingsAbout.m in Sources */,
|
54E9CF32225914300023696F /* SettingsAbout.m in Sources */,
|
||||||
54B749E0220636200022CC6D /* FeedArticle+Ext.m in Sources */,
|
54B749E0220636200022CC6D /* FeedArticle+Ext.m in Sources */,
|
||||||
|
54D55D7322E624CD00057B98 /* SettingsFeeds+DragDrop.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 */,
|
||||||
|
|||||||
@@ -28,37 +28,40 @@
|
|||||||
// TODO: Disable 'update all' menu item during update?
|
// TODO: Disable 'update all' menu item during update?
|
||||||
|
|
||||||
|
|
||||||
|
/// UTI type used for opml files
|
||||||
|
static const NSPasteboardType UTI_OPML = @"org.opml";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@c notification.object is @c NSNumber of type @c NSUInteger.
|
@c notification.object is @c NSNumber of type @c NSUInteger.
|
||||||
Represents number of feeds that are proccessed in background update. Sends @c 0 when all downloads are finished.
|
Represents number of feeds that are proccessed in background update. Sends @c 0 when all downloads are finished.
|
||||||
*/
|
*/
|
||||||
static NSString *kNotificationBackgroundUpdateInProgress = @"baRSS-notification-background-update-in-progress";
|
static const NSNotificationName kNotificationBackgroundUpdateInProgress = @"baRSS-notification-background-update-in-progress";
|
||||||
/**
|
/**
|
||||||
@c notification.object is @c NSManagedObjectID of type @c Feed.
|
@c notification.object is @c NSManagedObjectID of type @c Feed.
|
||||||
Called whenever download of a feed finished and object was modified (not if statusCode 304).
|
Called whenever download of a feed finished and object was modified (not if statusCode 304).
|
||||||
*/
|
*/
|
||||||
static NSString *kNotificationFeedUpdated = @"baRSS-notification-feed-updated";
|
static const NSNotificationName kNotificationFeedUpdated = @"baRSS-notification-feed-updated";
|
||||||
/**
|
/**
|
||||||
@c notification.object is @c NSManagedObjectID of type @c Feed.
|
@c notification.object is @c NSManagedObjectID of type @c Feed.
|
||||||
Called whenever the icon attribute of an item was updated.
|
Called whenever the icon attribute of an item was updated.
|
||||||
*/
|
*/
|
||||||
static NSString *kNotificationFeedIconUpdated = @"baRSS-notification-feed-icon-updated";
|
static const NSNotificationName kNotificationFeedIconUpdated = @"baRSS-notification-feed-icon-updated";
|
||||||
/**
|
/**
|
||||||
@c notification.object is @c NSNumber of type @c BOOL.
|
@c notification.object is @c NSNumber of type @c BOOL.
|
||||||
@c YES if network became reachable. @c NO on connection lost.
|
@c YES if network became reachable. @c NO on connection lost.
|
||||||
*/
|
*/
|
||||||
static NSString *kNotificationNetworkStatusChanged = @"baRSS-notification-network-status-changed";
|
static const NSNotificationName kNotificationNetworkStatusChanged = @"baRSS-notification-network-status-changed";
|
||||||
/**
|
/**
|
||||||
@c notification.object is @c NSNumber of type @c NSInteger.
|
@c notification.object is @c NSNumber of type @c NSInteger.
|
||||||
Represents a relative change (e.g., negative if items were marked read)
|
Represents a relative change (e.g., negative if items were marked read)
|
||||||
*/
|
*/
|
||||||
static NSString *kNotificationTotalUnreadCountChanged = @"baRSS-notification-total-unread-count-changed";
|
static const NSNotificationName kNotificationTotalUnreadCountChanged = @"baRSS-notification-total-unread-count-changed";
|
||||||
/**
|
/**
|
||||||
@c notification.object is either @c nil or @c NSNumber of type @c NSInteger.
|
@c notification.object is either @c nil or @c NSNumber of type @c NSInteger.
|
||||||
If new count is known an absoulte number is passed.
|
If new count is known an absoulte number is passed.
|
||||||
Else @c nil if count has to be fetched from core data.
|
Else @c nil if count has to be fetched from core data.
|
||||||
*/
|
*/
|
||||||
static NSString *kNotificationTotalUnreadCountReset = @"baRSS-notification-total-unread-count-reset";
|
static const NSNotificationName kNotificationTotalUnreadCountReset = @"baRSS-notification-total-unread-count-reset";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -39,13 +39,13 @@ typedef NS_ENUM(int16_t, FeedGroupType) {
|
|||||||
|
|
||||||
+ (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)setSortIndexIfChanged:(int32_t)sortIndex;
|
||||||
- (void)setNameIfChanged:(NSString*)name;
|
- (void)setNameIfChanged:(NSString*)name;
|
||||||
- (NSMenuItem*)newMenuItem;
|
- (NSMenuItem*)newMenuItem;
|
||||||
// Handle children and parents
|
// Handle children and parents
|
||||||
- (NSString*)indexPathString;
|
- (NSString*)indexPathString;
|
||||||
- (NSArray<FeedGroup*>*)sortedChildren;
|
- (NSArray<FeedGroup*>*)sortedChildren;
|
||||||
- (NSMutableArray<FeedGroup*>*)allParents;
|
- (NSMutableArray<FeedGroup*>*)allParents;
|
||||||
- (BOOL)iterateSorted:(BOOL)ordered overDescendantFeeds:(void(^)(Feed *feed, BOOL* cancel))block;
|
|
||||||
// Printing
|
// Printing
|
||||||
- (NSString*)readableDescription;
|
- (NSString*)readableDescription;
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -58,11 +58,15 @@
|
|||||||
+ (instancetype)newGroup:(FeedGroupType)type inContext:(NSManagedObjectContext*)moc {
|
+ (instancetype)newGroup:(FeedGroupType)type inContext:(NSManagedObjectContext*)moc {
|
||||||
FeedGroup *fg = [[FeedGroup alloc] initWithEntity: FeedGroup.entity insertIntoManagedObjectContext:moc];
|
FeedGroup *fg = [[FeedGroup alloc] initWithEntity: FeedGroup.entity insertIntoManagedObjectContext:moc];
|
||||||
fg.type = type;
|
fg.type = type;
|
||||||
if (type == FEED)
|
switch (type) {
|
||||||
fg.feed = [Feed newFeedAndMetaInContext:moc];
|
case GROUP: break;
|
||||||
|
case FEED: fg.feed = [Feed newFeedAndMetaInContext:moc]; break;
|
||||||
|
case SEPARATOR: fg.name = @"---"; break;
|
||||||
|
}
|
||||||
return fg;
|
return fg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set @c parent and @c sortIndex. Also if type is @c FEED calculate and set @c indexPath string.
|
||||||
- (void)setParent:(FeedGroup *)parent andSortIndex:(int32_t)sortIndex {
|
- (void)setParent:(FeedGroup *)parent andSortIndex:(int32_t)sortIndex {
|
||||||
self.parent = parent;
|
self.parent = parent;
|
||||||
self.sortIndex = sortIndex;
|
self.sortIndex = sortIndex;
|
||||||
@@ -70,6 +74,16 @@
|
|||||||
[self.feed calculateAndSetIndexPathString];
|
[self.feed calculateAndSetIndexPathString];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set @c sortIndex of @c FeedGroup. Iterate over all @c Feed child items and update @c indexPath string.
|
||||||
|
- (void)setSortIndexIfChanged:(int32_t)sortIndex {
|
||||||
|
if (self.sortIndex != sortIndex) {
|
||||||
|
self.sortIndex = sortIndex;
|
||||||
|
[self iterateSorted:NO overDescendantFeeds:^(Feed *feed, BOOL *cancel) {
|
||||||
|
[feed calculateAndSetIndexPathString];
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Set @c name attribute but only if value differs.
|
/// Set @c name attribute but only if value differs.
|
||||||
- (void)setNameIfChanged:(NSString*)name {
|
- (void)setNameIfChanged:(NSString*)name {
|
||||||
if (![self.name isEqualToString: name])
|
if (![self.name isEqualToString: name])
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>8048</string>
|
<string>9512</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
@@ -46,5 +46,35 @@
|
|||||||
<string>Copyright © 2019 relikd. Public Domain.</string>
|
<string>Copyright © 2019 relikd. Public Domain.</string>
|
||||||
<key>NSPrincipalClass</key>
|
<key>NSPrincipalClass</key>
|
||||||
<string>AppHook</string>
|
<string>AppHook</string>
|
||||||
|
<key>UTImportedTypeDeclarations</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UTTypeConformsTo</key>
|
||||||
|
<array>
|
||||||
|
<string>public.xml</string>
|
||||||
|
</array>
|
||||||
|
<key>UTTypeDescription</key>
|
||||||
|
<string>OPML file</string>
|
||||||
|
<key>UTTypeIconFile</key>
|
||||||
|
<string>AppIcon</string>
|
||||||
|
<key>UTTypeIdentifier</key>
|
||||||
|
<string>org.opml</string>
|
||||||
|
<key>UTTypeReferenceURL</key>
|
||||||
|
<string>http://dev.opml.org/spec2.html</string>
|
||||||
|
<key>UTTypeTagSpecification</key>
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.ostype</key>
|
||||||
|
<array/>
|
||||||
|
<key>public.filename-extension</key>
|
||||||
|
<array>
|
||||||
|
<string>opml</string>
|
||||||
|
</array>
|
||||||
|
<key>public.mime-type</key>
|
||||||
|
<array>
|
||||||
|
<string>text/xml</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -23,9 +23,43 @@
|
|||||||
#import <Foundation/Foundation.h>
|
#import <Foundation/Foundation.h>
|
||||||
#import <Cocoa/Cocoa.h>
|
#import <Cocoa/Cocoa.h>
|
||||||
|
|
||||||
@class Feed;
|
@class FeedGroup;
|
||||||
|
|
||||||
@interface OpmlExport : NSObject
|
typedef NS_OPTIONS(NSUInteger, OpmlFileExportOptions) {
|
||||||
+ (void)showImportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc;
|
OpmlFileExportOptionFlattened = 1 << 1,
|
||||||
+ (void)showExportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc;
|
OpmlFileExportOptionFullBackup = 1 << 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
#pragma mark - Protocols
|
||||||
|
|
||||||
|
@protocol OpmlFileImportDelegate <NSObject>
|
||||||
|
@required
|
||||||
|
- (NSManagedObjectContext*)opmlFileImportContext; // currently called only once
|
||||||
|
@optional
|
||||||
|
- (void)opmlFileImportWillBegin:(NSManagedObjectContext*)moc;
|
||||||
|
- (void)opmlFileImportDidEnd:(NSManagedObjectContext*)moc;
|
||||||
|
@end
|
||||||
|
|
||||||
|
|
||||||
|
@protocol OpmlFileExportDelegate <NSObject>
|
||||||
|
@required
|
||||||
|
- (NSArray<FeedGroup*>*)opmlFileExportListOfFeedGroups:(OpmlFileExportOptions)options;
|
||||||
|
@end
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - Classes
|
||||||
|
|
||||||
|
@interface OpmlFileImport : NSObject
|
||||||
|
@property (weak) id<OpmlFileImportDelegate> delegate;
|
||||||
|
+ (instancetype)withDelegate:(id<OpmlFileImportDelegate>)delegate;
|
||||||
|
- (void)showImportDialog:(NSWindow*)window;
|
||||||
|
- (void)importFiles:(NSArray<NSURL*>*)files;
|
||||||
|
@end
|
||||||
|
|
||||||
|
|
||||||
|
@interface OpmlFileExport : NSObject
|
||||||
|
@property (weak) id<OpmlFileExportDelegate> delegate;
|
||||||
|
+ (instancetype)withDelegate:(id<OpmlFileExportDelegate>)delegate;
|
||||||
|
- (void)showExportDialog:(NSWindow*)window;
|
||||||
|
- (nullable NSError*)writeOPMLFile:(NSURL*)url withOptions:(OpmlFileExportOptions)opt;
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -29,125 +29,99 @@
|
|||||||
#import "NSDate+Ext.h"
|
#import "NSDate+Ext.h"
|
||||||
#import "NSView+Ext.h"
|
#import "NSView+Ext.h"
|
||||||
|
|
||||||
@implementation OpmlExport
|
|
||||||
|
|
||||||
#pragma mark - Open & Save Panel
|
#pragma mark - Helper
|
||||||
|
|
||||||
/// Display Open File Panel to select @c .opml file. Perform web requests (feed data & icon) within a single undo group.
|
/// Loop over all subviews and find the @c NSButton that is selected.
|
||||||
+ (void)showImportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc {
|
NS_INLINE NSInteger RadioGroupSelection(NSView *view) {
|
||||||
NSOpenPanel *op = [NSOpenPanel openPanel];
|
for (NSButton *btn in view.subviews) {
|
||||||
op.allowedFileTypes = @[@"opml"];
|
if ([btn isKindOfClass:[NSButton class]] && btn.state == NSControlStateValueOn) {
|
||||||
[op beginSheetModalForWindow:window completionHandler:^(NSModalResponse result) {
|
return btn.tag;
|
||||||
if (result == NSModalResponseOK) {
|
|
||||||
NSData *data = [NSData dataWithContentsOfURL:op.URL];
|
|
||||||
RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:@"opml-file-import"];
|
|
||||||
RSOPMLParser *parser = [RSOPMLParser parserWithXMLData:xml];
|
|
||||||
[parser parseAsync:^(RSOPMLItem * _Nullable doc, NSError * _Nullable error) {
|
|
||||||
if (error) {
|
|
||||||
[NSApp presentError:error];
|
|
||||||
} else {
|
|
||||||
[self importOPMLDocument:doc inContext:moc];
|
|
||||||
}
|
|
||||||
}];
|
|
||||||
}
|
}
|
||||||
}];
|
}
|
||||||
}
|
return -1;
|
||||||
|
|
||||||
/// Display Save File Panel to select export destination. All feeds from core data will be exported.
|
|
||||||
+ (void)showExportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc {
|
|
||||||
NSSavePanel *sp = [NSSavePanel savePanel];
|
|
||||||
sp.nameFieldStringValue = [NSString stringWithFormat:@"baRSS feeds %@", [NSDate dayStringLocalized]];
|
|
||||||
sp.allowedFileTypes = @[@"opml"];
|
|
||||||
sp.allowsOtherFileTypes = YES;
|
|
||||||
NSView *radioView = [NSView radioGroup:@[NSLocalizedString(@"Hierarchical", nil),
|
|
||||||
NSLocalizedString(@"Flattened", nil)]];
|
|
||||||
sp.accessoryView = [NSView wrapView:radioView withLabel:NSLocalizedString(@"Export format:", nil) padding:PAD_M];
|
|
||||||
|
|
||||||
[sp beginSheetModalForWindow:window completionHandler:^(NSModalResponse result) {
|
|
||||||
if (result == NSModalResponseOK) {
|
|
||||||
BOOL flattened = ([self radioGroupSelection:radioView] == 1);
|
|
||||||
NSArray<FeedGroup*> *list = [StoreCoordinator sortedFeedGroupsWithParent:nil inContext:moc];
|
|
||||||
NSXMLDocument *doc = [self xmlDocumentForFeeds:list hierarchical:!flattened];
|
|
||||||
NSData *xml = [doc XMLDataWithOptions:NSXMLNodePreserveAttributeOrder | NSXMLNodePrettyPrint];
|
|
||||||
NSError *error;
|
|
||||||
[xml writeToURL:sp.URL options:NSDataWritingAtomic error:&error];
|
|
||||||
if (error) {
|
|
||||||
[NSApp presentError:error];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ################################################################
|
||||||
|
// #
|
||||||
|
// # OPML Import
|
||||||
|
// #
|
||||||
|
// ################################################################
|
||||||
#pragma mark - Import
|
#pragma mark - Import
|
||||||
|
|
||||||
|
@implementation OpmlFileImport
|
||||||
|
|
||||||
/**
|
+ (instancetype)withDelegate:(id<OpmlFileImportDelegate>)delegate {
|
||||||
Ask user for permission to import new items (prior import). User can choose to append or replace existing items.
|
OpmlFileImport *opml = [[super alloc] init];
|
||||||
If user chooses to replace existing items, perform core data request to delete all feeds.
|
opml.delegate = delegate;
|
||||||
|
return opml;
|
||||||
@param document Used to count feed items that will be imported
|
|
||||||
@return @c -1: User clicked 'Cancel' button. @c 0: Append items. @c 1: Overwrite items.
|
|
||||||
*/
|
|
||||||
+ (NSInteger)askToAppendOrOverwriteAlert:(RSOPMLItem*)document inContext:(NSManagedObjectContext*)moc {
|
|
||||||
NSUInteger count = [self recursiveNumberOfFeeds:document];
|
|
||||||
NSAlert *alert = [[NSAlert alloc] init];
|
|
||||||
alert.messageText = [NSString stringWithFormat:NSLocalizedString(@"Import of %lu feed items", nil), count];
|
|
||||||
alert.informativeText = NSLocalizedString(@"Do you want to append or replace existing items?", nil);
|
|
||||||
[alert addButtonWithTitle:NSLocalizedString(@"Import", nil)];
|
|
||||||
[alert addButtonWithTitle:NSLocalizedString(@"Cancel", nil)];
|
|
||||||
alert.accessoryView = [NSView radioGroup:@[NSLocalizedString(@"Append", nil),
|
|
||||||
NSLocalizedString(@"Overwrite", nil)]];
|
|
||||||
|
|
||||||
if ([alert runModal] == NSAlertFirstButtonReturn) {
|
|
||||||
return [self radioGroupSelection:alert.accessoryView];
|
|
||||||
}
|
|
||||||
return -1; // cancel button
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/// Display Open File Panel to select @c .opml file. Perform web requests (feed data & icon) within a single undo group.
|
||||||
Perform import of @c FeedGroup items.
|
- (void)showImportDialog:(NSWindow*)window {
|
||||||
*/
|
NSOpenPanel *op = [NSOpenPanel openPanel];
|
||||||
+ (void)importOPMLDocument:(RSOPMLItem*)doc inContext:(NSManagedObjectContext*)moc {
|
op.allowedFileTypes = @[UTI_OPML];
|
||||||
NSInteger select = [self askToAppendOrOverwriteAlert:doc inContext:moc];
|
op.allowsMultipleSelection = YES;
|
||||||
if (select < 0 || select > 1) // not a valid selection (or cancel button)
|
[op beginSheetModalForWindow:window completionHandler:^(NSModalResponse result) {
|
||||||
return;
|
if (result == NSModalResponseOK) {
|
||||||
|
[self importFiles:op.URLs];
|
||||||
[moc.undoManager beginUndoGrouping];
|
|
||||||
|
|
||||||
int32_t idx = 0;
|
|
||||||
if (select == 1) { // overwrite selected
|
|
||||||
for (FeedGroup *fg in [StoreCoordinator sortedFeedGroupsWithParent:nil inContext:moc]) {
|
|
||||||
[moc deleteObject:fg]; // Not a batch delete request to support undo
|
|
||||||
}
|
}
|
||||||
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:@0];
|
|
||||||
} else {
|
|
||||||
idx = (int32_t)[StoreCoordinator countRootItemsInContext:moc];
|
|
||||||
}
|
|
||||||
|
|
||||||
NSMutableArray<Feed*> *list = [NSMutableArray array];
|
|
||||||
for (RSOPMLItem *item in doc.children) {
|
|
||||||
[self importFeed:item parent:nil index:idx inContext:moc appendToList:list];
|
|
||||||
idx += 1;
|
|
||||||
}
|
|
||||||
// Persist state, because on crash we have at least inserted items (without articles & icons)
|
|
||||||
[StoreCoordinator saveContext:moc andParent:YES];
|
|
||||||
[FeedDownload batchDownloadFeeds:list favicons:YES showErrorAlert:YES finally:^{
|
|
||||||
[StoreCoordinator saveContext:moc andParent:YES];
|
|
||||||
[moc.undoManager endUndoGrouping];
|
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Perform core data import on all items of all @c files
|
||||||
|
- (void)importFiles:(NSArray<NSURL*>*)files {
|
||||||
|
id<OpmlFileImportDelegate> controller = self.delegate;
|
||||||
|
BOOL respondBegin = [controller respondsToSelector:@selector(opmlFileImportWillBegin:)];
|
||||||
|
BOOL respondEnd = [controller respondsToSelector:@selector(opmlFileImportDidEnd:)];
|
||||||
|
|
||||||
|
NSManagedObjectContext *moc = [controller opmlFileImportContext];
|
||||||
|
if (respondBegin)
|
||||||
|
[controller opmlFileImportWillBegin:moc];
|
||||||
|
|
||||||
|
NSUInteger lastIndex = [StoreCoordinator countRootItemsInContext:moc];
|
||||||
|
__block NSUInteger current = lastIndex;
|
||||||
|
[self enumerateFiles:files withBlock:^(RSOPMLItem *item) {
|
||||||
|
[self importFeed:item parent:nil index:(int32_t)current inContext:moc];
|
||||||
|
current += 1;
|
||||||
|
} finally:(!respondEnd ? nil : ^{ // ignore block if delegate doesn't respond
|
||||||
|
[controller opmlFileImportDidEnd:moc];
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loop over all files and parse XML data. Calls @c block() for each root @c RSOPMLItem.
|
||||||
|
- (void)enumerateFiles:(NSArray<NSURL*>*)files withBlock:(void(^)(RSOPMLItem *item))block finally:(nullable dispatch_block_t)finally {
|
||||||
|
dispatch_group_t group = dispatch_group_create();
|
||||||
|
for (NSURL *url in files) {
|
||||||
|
if (finally) dispatch_group_enter(group);
|
||||||
|
|
||||||
|
NSData *data = [NSData dataWithContentsOfURL:url];
|
||||||
|
RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:@"opml-file-import"];
|
||||||
|
RSOPMLParser *parser = [RSOPMLParser parserWithXMLData:xml];
|
||||||
|
[parser parseAsync:^(RSOPMLItem * _Nullable doc, NSError * _Nullable error) {
|
||||||
|
if (error) {
|
||||||
|
[NSApp presentError:error];
|
||||||
|
} else {
|
||||||
|
for (RSOPMLItem *itm in doc.children) {
|
||||||
|
block(itm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (finally) dispatch_group_leave(group);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
if (finally) dispatch_group_notify(group, dispatch_get_main_queue(), finally);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Import single item and recursively repeat import for each child.
|
Import single item and recursively repeat import for each child.
|
||||||
|
|
||||||
@param item The item to be imported.
|
@param item The item to be imported.
|
||||||
@param parent The already processed parent item.
|
@param parent The already processed parent item.
|
||||||
@param idx @c sortIndex within the @c parent item.
|
@param idx @c sortIndex within the @c parent item.
|
||||||
@param moc Managed object context.
|
@param moc Managed object context.
|
||||||
@param list Mutable list where newly inserted @c Feed items will be added.
|
|
||||||
*/
|
*/
|
||||||
+ (void)importFeed:(RSOPMLItem*)item parent:(FeedGroup*)parent index:(int32_t)idx inContext:(NSManagedObjectContext*)moc appendToList:(NSMutableArray<Feed*> *)list {
|
- (void)importFeed:(RSOPMLItem*)item parent:(FeedGroup*)parent index:(int32_t)idx inContext:(NSManagedObjectContext*)moc {
|
||||||
FeedGroupType type = GROUP;
|
FeedGroupType type = GROUP;
|
||||||
if ([item attributeForKey:OPMLXMLURLKey]) {
|
if ([item attributeForKey:OPMLXMLURLKey]) {
|
||||||
type = FEED;
|
type = FEED;
|
||||||
@@ -157,44 +131,129 @@
|
|||||||
|
|
||||||
FeedGroup *newFeed = [FeedGroup newGroup:type inContext:moc];
|
FeedGroup *newFeed = [FeedGroup newGroup:type inContext:moc];
|
||||||
[newFeed setParent:parent andSortIndex:idx];
|
[newFeed setParent:parent andSortIndex:idx];
|
||||||
newFeed.name = (type == SEPARATOR ? @"---" : item.displayName);
|
|
||||||
|
|
||||||
switch (type) {
|
if (type == SEPARATOR)
|
||||||
case GROUP:
|
return;
|
||||||
for (NSUInteger i = 0; i < item.children.count; i++) {
|
|
||||||
[self importFeed:item.children[i] parent:newFeed index:(int32_t)i inContext:moc appendToList:list];
|
newFeed.name = item.displayName;
|
||||||
}
|
|
||||||
break;
|
if (type == FEED) {
|
||||||
|
id refresh = [item attributeForKey:@"refreshInterval"]; // baRSS specific
|
||||||
case FEED:
|
int32_t interval = kDefaultFeedRefreshInterval; // TODO: set -1, then auto
|
||||||
@autoreleasepool {
|
if (refresh)
|
||||||
FeedMeta *meta = newFeed.feed.meta;
|
interval = (int32_t)[refresh integerValue];
|
||||||
meta.url = [item attributeForKey:OPMLXMLURLKey];
|
|
||||||
id refresh = [item attributeForKey:@"refreshInterval"]; // baRSS specific
|
newFeed.feed.meta.url = [item attributeForKey:OPMLXMLURLKey];
|
||||||
if (refresh) {
|
[newFeed.feed.meta setRefreshAndSchedule:interval];
|
||||||
[meta setRefreshAndSchedule:(int32_t)[refresh integerValue]];
|
} else { // GROUP
|
||||||
} else {
|
for (NSUInteger i = 0; i < item.children.count; i++) {
|
||||||
[meta setRefreshAndSchedule:kDefaultFeedRefreshInterval]; // TODO: set -1, then auto
|
[self importFeed:item.children[i] parent:newFeed index:(int32_t)i inContext:moc];
|
||||||
}
|
}
|
||||||
}
|
|
||||||
[list addObject:newFeed.feed];
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SEPARATOR:
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Ask user for permission to import new items (prior import). User can choose to append or replace existing items.
|
||||||
|
If user chooses to replace existing items, perform core data request to delete all feeds.
|
||||||
|
|
||||||
|
@param document Used to count feed items that will be imported
|
||||||
|
@return @c -1: User clicked 'Cancel' button. @c 0: Append items. @c 1: Overwrite items.
|
||||||
|
*/
|
||||||
|
//- (NSInteger)askToAppendOrOverwriteAlert:(RSOPMLItem*)document inContext:(NSManagedObjectContext*)moc {
|
||||||
|
// NSUInteger count = [self recursiveNumberOfFeeds:document];
|
||||||
|
// NSAlert *alert = [[NSAlert alloc] init];
|
||||||
|
// alert.messageText = [NSString stringWithFormat:NSLocalizedString(@"Import of %lu feed items", nil), count];
|
||||||
|
// alert.informativeText = NSLocalizedString(@"Do you want to append or replace existing items?", nil);
|
||||||
|
// [alert addButtonWithTitle:NSLocalizedString(@"Import", nil)];
|
||||||
|
// [alert addButtonWithTitle:NSLocalizedString(@"Cancel", nil)];
|
||||||
|
// alert.accessoryView = [NSView radioGroup:@[NSLocalizedString(@"Append", nil),
|
||||||
|
// NSLocalizedString(@"Overwrite", nil)]];
|
||||||
|
//
|
||||||
|
// if ([alert runModal] == NSAlertFirstButtonReturn) {
|
||||||
|
// return RadioGroupSelection(alert.accessoryView);
|
||||||
|
// }
|
||||||
|
// return -1; // cancel button
|
||||||
|
//}
|
||||||
|
|
||||||
|
/// Count items where @c xmlURL key is set.
|
||||||
|
//- (NSUInteger)recursiveNumberOfFeeds:(RSOPMLItem*)document {
|
||||||
|
// if ([document attributeForKey:OPMLXMLURLKey]) {
|
||||||
|
// return 1;
|
||||||
|
// } else {
|
||||||
|
// NSUInteger sum = 0;
|
||||||
|
// for (RSOPMLItem *child in document.children) {
|
||||||
|
// sum += [self recursiveNumberOfFeeds:child];
|
||||||
|
// }
|
||||||
|
// return sum;
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
|
||||||
|
// ################################################################
|
||||||
|
// #
|
||||||
|
// # OPML Export
|
||||||
|
// #
|
||||||
|
// ################################################################
|
||||||
#pragma mark - Export
|
#pragma mark - Export
|
||||||
|
|
||||||
|
@implementation OpmlFileExport
|
||||||
|
|
||||||
|
+ (instancetype)withDelegate:(id<OpmlFileExportDelegate>)delegate {
|
||||||
|
OpmlFileExport *opml = [[super alloc] init];
|
||||||
|
opml.delegate = delegate;
|
||||||
|
return opml;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display Save File Panel to select file destination.
|
||||||
|
- (void)showExportDialog:(NSWindow*)window {
|
||||||
|
NSSavePanel *sp = [NSSavePanel savePanel];
|
||||||
|
sp.nameFieldStringValue = [NSString stringWithFormat:@"baRSS feeds %@", [NSDate dayStringLocalized]];
|
||||||
|
sp.allowedFileTypes = @[UTI_OPML];
|
||||||
|
sp.allowsOtherFileTypes = YES;
|
||||||
|
NSView *radioView = [NSView radioGroup:@[NSLocalizedString(@"Hierarchical", nil),
|
||||||
|
NSLocalizedString(@"Flattened", nil)]];
|
||||||
|
sp.accessoryView = [NSView wrapView:radioView withLabel:NSLocalizedString(@"Export format:", nil) padding:PAD_M];
|
||||||
|
|
||||||
|
[sp beginSheetModalForWindow:window completionHandler:^(NSModalResponse result) {
|
||||||
|
if (result == NSModalResponseOK) {
|
||||||
|
OpmlFileExportOptions opt = OpmlFileExportOptionFullBackup;
|
||||||
|
if (RadioGroupSelection(radioView) == 1)
|
||||||
|
opt |= OpmlFileExportOptionFlattened;
|
||||||
|
[self writeOPMLFile:sp.URL withOptions:opt];
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Convert list of @c FeedGroup to @c NSXMLDocument and write to local file @c url.
|
||||||
|
On error: show application alert (which is also returned).
|
||||||
|
|
||||||
|
@note Calls @c opmlExportListOfFeedGroups: on @c delegate to obtain export list.
|
||||||
|
*/
|
||||||
|
- (nullable NSError*)writeOPMLFile:(NSURL*)url withOptions:(OpmlFileExportOptions)opt {
|
||||||
|
NSArray<FeedGroup*> *list = [self.delegate opmlFileExportListOfFeedGroups:opt];
|
||||||
|
NSError *error;
|
||||||
|
// TODO: set error if nil or empty
|
||||||
|
if (list.count > 0) {
|
||||||
|
BOOL keepTree = !(opt & OpmlFileExportOptionFlattened);
|
||||||
|
NSXMLDocument *doc = [self xmlDocumentForFeeds:list hierarchical:keepTree];
|
||||||
|
NSData *xml = [doc XMLDataWithOptions:NSXMLNodePreserveAttributeOrder | NSXMLNodePrettyPrint];
|
||||||
|
[xml writeToURL:url options:NSDataWritingAtomic error:&error];
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
[NSApp presentError:error];
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Create NSXMLNode structure with application header nodes and body node containing feed items.
|
Create NSXMLNode structure with application header nodes and body node containing feed items.
|
||||||
|
|
||||||
@param flag If @c YES keep parent-child structure intact. If @c NO ignore all parents and add @c Feed items only.
|
@param flag If @c YES keep parent-child structure intact. If @c NO ignore all parents and add @c Feed items only.
|
||||||
*/
|
*/
|
||||||
+ (NSXMLDocument*)xmlDocumentForFeeds:(NSArray<FeedGroup*>*)list hierarchical:(BOOL)flag {
|
- (NSXMLDocument*)xmlDocumentForFeeds:(NSArray<FeedGroup*>*)list hierarchical:(BOOL)flag {
|
||||||
NSXMLElement *head = [NSXMLElement elementWithName:@"head"];
|
NSXMLElement *head = [NSXMLElement elementWithName:@"head"];
|
||||||
head.children = @[[NSXMLElement elementWithName:@"title" stringValue:@"baRSS feeds"],
|
head.children = @[[NSXMLElement elementWithName:@"title" stringValue:@"baRSS feeds"],
|
||||||
[NSXMLElement elementWithName:@"ownerName" stringValue:@"baRSS"],
|
[NSXMLElement elementWithName:@"ownerName" stringValue:@"baRSS"],
|
||||||
@@ -220,7 +279,7 @@
|
|||||||
|
|
||||||
@param flag If @c NO don't add groups to export file but continue evaluation of child items.
|
@param flag If @c NO don't add groups to export file but continue evaluation of child items.
|
||||||
*/
|
*/
|
||||||
+ (void)appendChild:(FeedGroup*)item toNode:(NSXMLElement *)parent hierarchical:(BOOL)flag {
|
- (void)appendChild:(FeedGroup*)item toNode:(NSXMLElement *)parent hierarchical:(BOOL)flag {
|
||||||
if (flag || item.type != GROUP) {
|
if (flag || item.type != GROUP) {
|
||||||
// dont add group node if hierarchical == NO
|
// dont add group node if hierarchical == NO
|
||||||
NSXMLElement *outline = [NSXMLElement elementWithName:@"outline"];
|
NSXMLElement *outline = [NSXMLElement elementWithName:@"outline"];
|
||||||
@@ -245,31 +304,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - Helper
|
|
||||||
|
|
||||||
|
|
||||||
/// Count items where @c xmlURL key is set.
|
|
||||||
+ (NSUInteger)recursiveNumberOfFeeds:(RSOPMLItem*)document {
|
|
||||||
if ([document attributeForKey:OPMLXMLURLKey]) {
|
|
||||||
return 1;
|
|
||||||
} else {
|
|
||||||
NSUInteger sum = 0;
|
|
||||||
for (RSOPMLItem *child in document.children) {
|
|
||||||
sum += [self recursiveNumberOfFeeds:child];
|
|
||||||
}
|
|
||||||
return sum;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loop over all subviews and find the @c NSButton that is selected.
|
|
||||||
+ (NSInteger)radioGroupSelection:(NSView*)view {
|
|
||||||
for (NSButton *btn in view.subviews) {
|
|
||||||
if ([btn isKindOfClass:[NSButton class]] && btn.state == NSControlStateValueOn) {
|
|
||||||
return btn.tag;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
28
baRSS/Preferences/Feeds Tab/SettingsFeeds+DragDrop.h
Normal file
28
baRSS/Preferences/Feeds Tab/SettingsFeeds+DragDrop.h
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
//
|
||||||
|
// 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 "SettingsFeeds.h"
|
||||||
|
#import "OpmlExport.h"
|
||||||
|
|
||||||
|
@interface SettingsFeeds (DragDrop) <NSOutlineViewDataSource, NSFilePromiseProviderDelegate, NSPasteboardTypeOwner, OpmlFileImportDelegate, OpmlFileExportDelegate>
|
||||||
|
- (void)prepareOutlineViewForDragDrop:(NSOutlineView*)outline;
|
||||||
|
@end
|
||||||
245
baRSS/Preferences/Feeds Tab/SettingsFeeds+DragDrop.m
Normal file
245
baRSS/Preferences/Feeds Tab/SettingsFeeds+DragDrop.m
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
//
|
||||||
|
// 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 "SettingsFeeds+DragDrop.h"
|
||||||
|
#import "StoreCoordinator.h"
|
||||||
|
#import "Constants.h"
|
||||||
|
#import "FeedDownload.h"
|
||||||
|
#import "FeedGroup+Ext.h"
|
||||||
|
|
||||||
|
// Pasteboard type used during internal row reordering
|
||||||
|
const NSPasteboardType dragReorder = @"de.relikd.baRSS.drag-reorder";
|
||||||
|
|
||||||
|
@implementation SettingsFeeds (DragDrop)
|
||||||
|
|
||||||
|
/// Set self as @c dataSource and register drag types
|
||||||
|
- (void)prepareOutlineViewForDragDrop:(NSOutlineView*)outline {
|
||||||
|
outline.dataSource = self;
|
||||||
|
[outline registerForDraggedTypes:@[dragReorder, (NSPasteboardType)kUTTypeFileURL]];
|
||||||
|
[outline setDraggingSourceOperationMask:NSDragOperationMove forLocal:YES]; // reorder
|
||||||
|
[outline setDraggingSourceOperationMask:NSDragOperationCopy forLocal:NO]; // export
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - Dragging Support, Data Source Delegate
|
||||||
|
|
||||||
|
|
||||||
|
/// Begin drag-n-drop operation by copying selected nodes to memory & prepare @c FilePromise
|
||||||
|
- (BOOL)outlineView:(NSOutlineView *)outlineView writeItems:(NSArray *)items toPasteboard:(NSPasteboard *)pasteboard {
|
||||||
|
NSFilePromiseProvider *opml = [[NSFilePromiseProvider alloc] initWithFileType:UTI_OPML delegate:self];
|
||||||
|
[pasteboard writeObjects:@[opml]]; // opml file export
|
||||||
|
[pasteboard setString:@"dragging" forType:dragReorder]; // internal row reordering
|
||||||
|
[pasteboard addTypes:@[NSPasteboardTypeString] owner:self]; // string export, same as Cmd-C
|
||||||
|
self.currentlyDraggedNodes = items;
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear previous memory after drag operation
|
||||||
|
- (void)outlineView:(NSOutlineView *)outlineView draggingSession:(NSDraggingSession *)session endedAtPoint:(NSPoint)screenPoint operation:(NSDragOperation)operation {
|
||||||
|
self.currentlyDraggedNodes = nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prohibit drag if destination is leaf or source has no opml
|
||||||
|
- (NSDragOperation)outlineView:(NSOutlineView *)outlineView validateDrop:(id <NSDraggingInfo>)info proposedItem:(NSTreeNode*)parent proposedChildIndex:(NSInteger)index {
|
||||||
|
if (info.numberOfValidItemsForDrop == 0 // none of the files is opml
|
||||||
|
|| (index == -1 && [parent isLeaf])) { // drag on specific item (-1) that is not a group
|
||||||
|
return NSDragOperationNone;
|
||||||
|
}
|
||||||
|
if (info.draggingSource == outlineView) {
|
||||||
|
// Internal item reordering (dragReorder)
|
||||||
|
for (NSTreeNode *selection in self.currentlyDraggedNodes) {
|
||||||
|
if (IndexPathIsChildOfParent(parent.indexPath, selection.indexPath))
|
||||||
|
return NSDragOperationNone; // cannot move items into a child of its own
|
||||||
|
}
|
||||||
|
return NSDragOperationMove;
|
||||||
|
} else {
|
||||||
|
// Dropped file urls, set whole table as destination
|
||||||
|
[outlineView setDropItem:nil dropChildIndex:NSOutlineViewDropOnItemIndex];
|
||||||
|
return NSDragOperationGeneric;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform drag-n-drop operation, move nodes to new destination and update all indices
|
||||||
|
- (BOOL)outlineView:(NSOutlineView *)outlineView acceptDrop:(id <NSDraggingInfo>)info item:(NSTreeNode*)newParent childIndex:(NSInteger)index {
|
||||||
|
if (info.numberOfValidItemsForDrop == 0)
|
||||||
|
return NO;
|
||||||
|
|
||||||
|
if (info.draggingSource == outlineView) {
|
||||||
|
// Calculate drop path
|
||||||
|
if (!newParent) newParent = [self.dataStore arrangedObjects]; // root
|
||||||
|
NSUInteger idx = (NSUInteger)index;
|
||||||
|
if (index == -1) // if folder, append to end
|
||||||
|
idx = newParent.childNodes.count;
|
||||||
|
|
||||||
|
// Internal item reordering (dragReorder)
|
||||||
|
[self beginCoreDataChange];
|
||||||
|
NSArray<NSTreeNode*> *previousParents = [self.currentlyDraggedNodes valueForKeyPath:@"parentNode"];
|
||||||
|
[self.dataStore moveNodes:self.currentlyDraggedNodes toIndexPath:[newParent.indexPath indexPathByAddingIndex:idx]];
|
||||||
|
[self restoreOrderingAndIndexPathStr:[previousParents arrayByAddingObject:newParent]];
|
||||||
|
[self endCoreDataChangeUndoEmpty:YES forceUndo:NO];
|
||||||
|
} else {
|
||||||
|
// File import
|
||||||
|
NSArray<NSURL*> *files = [info.draggingPasteboard readObjectsForClasses:@[NSURL.class] options:@{ NSPasteboardURLReadingContentsConformToTypesKey: @[UTI_OPML] }];
|
||||||
|
[[OpmlFileImport withDelegate:self] importFiles:files];
|
||||||
|
}
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - OPML File Import
|
||||||
|
|
||||||
|
|
||||||
|
/// Filter out file urls that are not opml files
|
||||||
|
- (void)outlineView:(NSOutlineView *)outlineView updateDraggingItemsForDrag:(id <NSDraggingInfo>)info {
|
||||||
|
if ([info.draggingPasteboard canReadItemWithDataConformingToTypes:@[(NSPasteboardType)kUTTypeFileURL]]) {
|
||||||
|
NSDraggingItemEnumerationOptions opt = NSDraggingItemEnumerationClearNonenumeratedImages;
|
||||||
|
NSArray<Class> *cls = @[ [NSURL class] ];
|
||||||
|
NSDictionary *dict = @{ NSPasteboardURLReadingContentsConformToTypesKey: @[UTI_OPML] };
|
||||||
|
__block NSInteger count = 0;
|
||||||
|
[info enumerateDraggingItemsWithOptions:opt forView:nil classes:cls searchOptions:dict usingBlock:^(NSDraggingItem * _Nonnull draggingItem, NSInteger idx, BOOL * _Nonnull stop) {
|
||||||
|
++count;
|
||||||
|
}];
|
||||||
|
info.numberOfValidItemsForDrop = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OPML import (context provider)
|
||||||
|
- (NSManagedObjectContext *)opmlFileImportContext {
|
||||||
|
return self.dataStore.managedObjectContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OPML import (will begin)
|
||||||
|
- (void)opmlFileImportWillBegin:(NSManagedObjectContext*)moc {
|
||||||
|
[self beginCoreDataChange];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OPML import (did end). Save changes, select newly inserted, and perform web request.
|
||||||
|
- (void)opmlFileImportDidEnd:(NSManagedObjectContext*)moc {
|
||||||
|
if (!moc.hasChanges) { // exit early, dont need to create empty arrays
|
||||||
|
[self endCoreDataChangeUndoEmpty:YES forceUndo:YES];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Get list of feeds, and root level selection
|
||||||
|
NSUInteger count = moc.insertedObjects.count;
|
||||||
|
NSMutableArray<NSIndexPath*> *selection = [NSMutableArray arrayWithCapacity:count];
|
||||||
|
NSMutableArray<Feed*> *feedsList = [NSMutableArray arrayWithCapacity:count];
|
||||||
|
for (__kindof NSManagedObject *obj in moc.insertedObjects) {
|
||||||
|
if ([obj isKindOfClass:[Feed class]]) {
|
||||||
|
[feedsList addObject:obj]; // list of feeds that need download
|
||||||
|
} else if ([obj isKindOfClass:[FeedGroup class]]) {
|
||||||
|
FeedGroup *fg = obj;
|
||||||
|
if (fg.parent == nil) // list of root level parents
|
||||||
|
[selection addObject:[NSIndexPath indexPathWithIndex:(NSUInteger)fg.sortIndex]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Persist state, because on crash we have at least inserted items (without articles & icons)
|
||||||
|
[StoreCoordinator saveContext:moc andParent:YES];
|
||||||
|
[self.dataStore setSelectionIndexPaths:selection];
|
||||||
|
[FeedDownload batchDownloadFeeds:feedsList favicons:YES showErrorAlert:YES finally:^{
|
||||||
|
[self endCoreDataChangeUndoEmpty:NO forceUndo:NO];
|
||||||
|
[self someDatesChangedScheduleUpdateTimer];
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - OPML File Export
|
||||||
|
|
||||||
|
|
||||||
|
/// OPML export with drag-n-drop (filename)
|
||||||
|
- (nonnull NSString *)filePromiseProvider:(nonnull NSFilePromiseProvider *)filePromiseProvider fileNameForType:(nonnull NSString *)fileType {
|
||||||
|
CFStringRef ext = UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)(fileType), kUTTagClassFilenameExtension);
|
||||||
|
return [@"baRSS export" stringByAppendingPathExtension: CFBridgingRelease(ext)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OPML export with drag-n-drop (write)
|
||||||
|
- (void)filePromiseProvider:(nonnull NSFilePromiseProvider *)filePromiseProvider writePromiseToURL:(nonnull NSURL *)url completionHandler:(nonnull void (^)(NSError * _Nullable))completionHandler {
|
||||||
|
NSError *err = [[OpmlFileExport withDelegate:self] writeOPMLFile:url withOptions:0];
|
||||||
|
completionHandler(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OPML export: drag-n-drop & menu export (content provider)
|
||||||
|
- (NSArray<FeedGroup*>*)opmlFileExportListOfFeedGroups:(OpmlFileExportOptions)options {
|
||||||
|
if (options & OpmlFileExportOptionFullBackup) // through button or menu click
|
||||||
|
return [self.dataStore.arrangedObjects.childNodes valueForKeyPath:@"representedObject"];
|
||||||
|
// drag-n-drop with file promise provider
|
||||||
|
return [[self draggedTopLevelNodes] valueForKeyPath:@"representedObject"];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - String Export
|
||||||
|
|
||||||
|
|
||||||
|
/// Called during export for @c NSPasteboardTypeString (text drag and copy:)
|
||||||
|
- (void)pasteboard:(NSPasteboard *)sender provideDataForType:(NSPasteboardType)type {
|
||||||
|
if (type == NSPasteboardTypeString) {
|
||||||
|
NSMutableString *str = [[NSMutableString alloc] init];
|
||||||
|
for (NSTreeNode *node in [self draggedTopLevelNodes]) {
|
||||||
|
[self traverseChildren:node appendString:str prefix:@""];
|
||||||
|
}
|
||||||
|
[str deleteCharactersInRange: NSMakeRange(str.length - 1, 1)]; // delete trailing new-line
|
||||||
|
[sender setString:str forType:type];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Go through all children recursively and prepend the string with spaces as nesting
|
||||||
|
@param obj Root Node or parent Node
|
||||||
|
@param str An initialized @c NSMutableString to append to
|
||||||
|
@param prefix Should be @c @@"" for the first call
|
||||||
|
*/
|
||||||
|
- (void)traverseChildren:(NSTreeNode*)obj appendString:(NSMutableString*)str prefix:(NSString*)prefix {
|
||||||
|
FeedGroup *fg = obj.representedObject;
|
||||||
|
[str appendFormat:@"%@%@\n", prefix, [fg readableDescription]];
|
||||||
|
prefix = [prefix stringByAppendingString:@" "];
|
||||||
|
for (NSTreeNode *child in obj.childNodes) {
|
||||||
|
[self traverseChildren:child appendString:str prefix:prefix];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - Helper Methods
|
||||||
|
|
||||||
|
|
||||||
|
/// Selection without redundant nodes that are already present in some selected parent node
|
||||||
|
- (NSArray<NSTreeNode*>*)draggedTopLevelNodes {
|
||||||
|
NSArray *nodes = self.currentlyDraggedNodes;
|
||||||
|
if (!nodes) nodes = self.dataStore.selectedNodes; // fallback to selection (e.g., Cmd-C)
|
||||||
|
NSMutableArray<NSTreeNode*> *result = [NSMutableArray arrayWithCapacity:nodes.count];
|
||||||
|
for (NSTreeNode *current in nodes) {
|
||||||
|
BOOL skip = NO;
|
||||||
|
for (NSTreeNode *stored in result) {
|
||||||
|
if (IndexPathIsChildOfParent(current.indexPath, stored.indexPath)) {
|
||||||
|
skip = YES; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (skip == NO) [result addObject:current];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
NS_INLINE BOOL IndexPathIsChildOfParent(NSIndexPath *child, NSIndexPath *parent) {
|
||||||
|
while (child.length > parent.length)
|
||||||
|
child = [child indexPathByRemovingLastIndex];
|
||||||
|
return [child isEqualTo:parent];
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -23,8 +23,9 @@
|
|||||||
#import <Cocoa/Cocoa.h>
|
#import <Cocoa/Cocoa.h>
|
||||||
|
|
||||||
/** Manages the NSOutlineView and Feed creation and editing */
|
/** Manages the NSOutlineView and Feed creation and editing */
|
||||||
@interface SettingsFeeds : NSViewController <NSOutlineViewDataSource, NSOutlineViewDelegate>
|
@interface SettingsFeeds : NSViewController <NSOutlineViewDelegate>
|
||||||
@property (strong) NSTreeController *dataStore;
|
@property (strong) NSTreeController *dataStore;
|
||||||
|
@property (strong) NSArray<NSTreeNode*> *currentlyDraggedNodes;
|
||||||
|
|
||||||
- (void)editSelectedItem;
|
- (void)editSelectedItem;
|
||||||
- (void)doubleClickOutlineView:(NSOutlineView*)sender;
|
- (void)doubleClickOutlineView:(NSOutlineView*)sender;
|
||||||
@@ -34,4 +35,9 @@
|
|||||||
- (void)remove:(id)sender;
|
- (void)remove:(id)sender;
|
||||||
- (void)openImportDialog;
|
- (void)openImportDialog;
|
||||||
- (void)openExportDialog;
|
- (void)openExportDialog;
|
||||||
|
|
||||||
|
- (void)beginCoreDataChange;
|
||||||
|
- (BOOL)endCoreDataChangeUndoEmpty:(BOOL)undoEmpty forceUndo:(BOOL)force;
|
||||||
|
- (void)someDatesChangedScheduleUpdateTimer;
|
||||||
|
- (void)restoreOrderingAndIndexPathStr:(NSArray<NSTreeNode*>*)parentsList;
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -20,37 +20,29 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
#import "SettingsFeeds.h"
|
#import "SettingsFeeds+DragDrop.h"
|
||||||
#import "Constants.h"
|
#import "Constants.h"
|
||||||
#import "StoreCoordinator.h"
|
#import "StoreCoordinator.h"
|
||||||
#import "ModalFeedEdit.h"
|
#import "ModalFeedEdit.h"
|
||||||
#import "Feed+Ext.h"
|
|
||||||
#import "FeedGroup+Ext.h"
|
#import "FeedGroup+Ext.h"
|
||||||
#import "OpmlExport.h"
|
|
||||||
#import "FeedDownload.h"
|
#import "FeedDownload.h"
|
||||||
#import "SettingsFeedsView.h"
|
#import "SettingsFeedsView.h"
|
||||||
#import "NSDate+Ext.h"
|
#import "NSDate+Ext.h"
|
||||||
|
|
||||||
@interface SettingsFeeds ()
|
@interface SettingsFeeds ()
|
||||||
@property (strong) SettingsFeedsView *view; // override super
|
@property (strong) SettingsFeedsView *view; // override super
|
||||||
|
|
||||||
@property (strong) NSArray<NSTreeNode*> *currentlyDraggedNodes;
|
|
||||||
@property (strong) NSUndoManager *undoManager;
|
@property (strong) NSUndoManager *undoManager;
|
||||||
|
|
||||||
@property (strong) NSTimer *timerStatusInfo;
|
@property (strong) NSTimer *timerStatusInfo;
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@implementation SettingsFeeds
|
@implementation SettingsFeeds
|
||||||
@dynamic view;
|
@dynamic view;
|
||||||
|
|
||||||
// TODO: drag-n-drop feeds to opml file?
|
|
||||||
// Declare a string constant for the drag type - to be used when writing and retrieving pasteboard data...
|
|
||||||
static NSString *dragNodeType = @"baRSS-feed-drag";
|
|
||||||
|
|
||||||
- (void)loadView {
|
- (void)loadView {
|
||||||
[self initCoreDataStore];
|
[self initCoreDataStore];
|
||||||
self.view = [[SettingsFeedsView alloc] initWithController:self];
|
self.view = [[SettingsFeedsView alloc] initWithController:self];
|
||||||
[self.view.outline registerForDraggedTypes:[NSArray arrayWithObject:dragNodeType]];
|
self.view.outline.delegate = self; // viewForTableColumn
|
||||||
|
[self prepareOutlineViewForDragDrop:self.view.outline];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)viewDidLoad {
|
- (void)viewDidLoad {
|
||||||
@@ -65,6 +57,28 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Initialize status info timer
|
||||||
|
- (void)viewWillAppear {
|
||||||
|
// needed to scroll outline view to top (if prefs open on another tab)
|
||||||
|
[self.dataStore setSelectionIndexPath:[NSIndexPath indexPathWithIndex:0]];
|
||||||
|
self.timerStatusInfo = [NSTimer timerWithTimeInterval:NSTimeIntervalSince1970 target:self selector:@selector(keepTimerRunning) userInfo:nil repeats:YES];
|
||||||
|
[[NSRunLoop mainRunLoop] addTimer:self.timerStatusInfo forMode:NSRunLoopCommonModes];
|
||||||
|
// start spinner if update is in progress when preferences open
|
||||||
|
[self activateSpinner:([FeedDownload isUpdating] ? -1 : 0)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Timer cleanup
|
||||||
|
- (void)viewWillDisappear {
|
||||||
|
// in viewWillDisappear otherwise dealloc will not be called
|
||||||
|
[self.timerStatusInfo invalidate];
|
||||||
|
self.timerStatusInfo = nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - Persist state
|
||||||
|
|
||||||
|
|
||||||
|
/// Prepare undo manager and tree controller
|
||||||
- (void)initCoreDataStore {
|
- (void)initCoreDataStore {
|
||||||
self.undoManager = [[NSUndoManager alloc] init];
|
self.undoManager = [[NSUndoManager alloc] init];
|
||||||
self.undoManager.groupsByEvent = NO;
|
self.undoManager.groupsByEvent = NO;
|
||||||
@@ -87,26 +101,71 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Refresh current context from parent context and start new undo grouping.
|
||||||
|
@note Should be balanced with @c endCoreDataChangeUndoEmpty:forceUndo:
|
||||||
|
*/
|
||||||
|
- (void)beginCoreDataChange {
|
||||||
|
// Does seem to create problems with undo stack if refreshing from parent context
|
||||||
|
//[self.dataStore.managedObjectContext refreshAllObjects];
|
||||||
|
[self.undoManager beginUndoGrouping];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
End undo grouping and save changes to persistent store. Or undo group if no changes occured.
|
||||||
|
@note Should be balanced with @c beginCoreDataChange
|
||||||
|
|
||||||
|
@param undoEmpty If @c YES undo the last operation if no changes were made (unnecessary undo).
|
||||||
|
@param force If @c YES force @c NSUndoManager to undo the changes immediatelly.
|
||||||
|
@return Returns @c YES if context was saved.
|
||||||
|
*/
|
||||||
|
- (BOOL)endCoreDataChangeUndoEmpty:(BOOL)undoEmpty forceUndo:(BOOL)force {
|
||||||
|
[self.undoManager endUndoGrouping];
|
||||||
|
if (force || (undoEmpty && !self.dataStore.managedObjectContext.hasChanges)) {
|
||||||
|
[self.undoManager disableUndoRegistration];
|
||||||
|
[self.undoManager undoNestedGroup];
|
||||||
|
[self.undoManager enableUndoRegistration];
|
||||||
|
return NO;
|
||||||
|
}
|
||||||
|
[StoreCoordinator saveContext:self.dataStore.managedObjectContext andParent:YES];
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After the user did undo or redo we can't ensure integrity without doing some additional work.
|
||||||
|
- (void)saveWithUnpredictableChange {
|
||||||
|
// dont use unless you merge changes from main
|
||||||
|
// NSManagedObjectContext *moc = self.dataStore.managedObjectContext;
|
||||||
|
// NSPredicate *pred = [NSPredicate predicateWithFormat:@"class == %@", [FeedArticle class]];
|
||||||
|
// NSInteger del = [[[moc.deletedObjects filteredSetUsingPredicate:pred] valueForKeyPath:@"@sum.unread"] integerValue];
|
||||||
|
// NSInteger ins = [[[moc.insertedObjects filteredSetUsingPredicate:pred] valueForKeyPath:@"@sum.unread"] integerValue];
|
||||||
|
// NSLog(@"%ld, %ld", del, ins);
|
||||||
|
[StoreCoordinator saveContext:self.dataStore.managedObjectContext andParent:YES];
|
||||||
|
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
|
||||||
|
[self.dataStore rearrangeObjects]; // update ordering
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query core data for next update date and set bottom status message
|
||||||
|
- (void)someDatesChangedScheduleUpdateTimer {
|
||||||
|
[FeedDownload scheduleUpdateForUpcomingFeeds];
|
||||||
|
[self.timerStatusInfo fire];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Callback method fired when feed (or icon) has been updated in the background.
|
||||||
|
- (void)feedUpdated:(NSNotification*)notify {
|
||||||
|
NSManagedObjectID *oid = notify.object;
|
||||||
|
NSManagedObjectContext *moc = self.dataStore.managedObjectContext;
|
||||||
|
Feed *feed = [moc objectRegisteredForID:oid];
|
||||||
|
if (feed) {
|
||||||
|
if (self.undoManager.groupingLevel == 0) // don't mess around if user is editing something
|
||||||
|
[moc refreshObject:feed mergeChanges:YES];
|
||||||
|
[self.dataStore rearrangeObjects]; // update display, show new icon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - Activity Spinner & Status Info
|
#pragma mark - Activity Spinner & Status Info
|
||||||
|
|
||||||
|
|
||||||
/// Initialize status info timer
|
|
||||||
- (void)viewWillAppear {
|
|
||||||
[self.dataStore rearrangeObjects]; // needed to scroll outline view to top (if prefs open on another tab)
|
|
||||||
self.timerStatusInfo = [NSTimer timerWithTimeInterval:NSTimeIntervalSince1970 target:self selector:@selector(keepTimerRunning) userInfo:nil repeats:YES];
|
|
||||||
[[NSRunLoop mainRunLoop] addTimer:self.timerStatusInfo forMode:NSRunLoopCommonModes];
|
|
||||||
// start spinner if update is in progress when preferences open
|
|
||||||
[self activateSpinner:([FeedDownload isUpdating] ? -1 : 0)];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Timer cleanup
|
|
||||||
- (void)viewWillDisappear {
|
|
||||||
// in viewWillDisappear otherwise dealloc will not be called
|
|
||||||
[self.timerStatusInfo invalidate];
|
|
||||||
self.timerStatusInfo = nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Callback method to update status info. Will be called more often when interval is getting shorter.
|
/// Callback method to update status info. Will be called more often when interval is getting shorter.
|
||||||
- (void)keepTimerRunning {
|
- (void)keepTimerRunning {
|
||||||
NSDate *date = [FeedDownload dateScheduled];
|
NSDate *date = [FeedDownload dateScheduled];
|
||||||
@@ -143,93 +202,25 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - Notification callback methods
|
|
||||||
|
|
||||||
|
|
||||||
/// Callback method fired when feed (or icon) has been updated in the background.
|
|
||||||
- (void)feedUpdated:(NSNotification*)notify {
|
|
||||||
NSManagedObjectID *oid = notify.object;
|
|
||||||
NSManagedObjectContext *moc = self.dataStore.managedObjectContext;
|
|
||||||
Feed *feed = [moc objectRegisteredForID:oid];
|
|
||||||
if (feed) {
|
|
||||||
if (self.undoManager.groupingLevel == 0) // don't mess around if user is editing something
|
|
||||||
[moc refreshObject:feed mergeChanges:YES];
|
|
||||||
[self.dataStore rearrangeObjects];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Callback method fired when background feed update begins and ends.
|
/// Callback method fired when background feed update begins and ends.
|
||||||
- (void)updateInProgress:(NSNotification*)notify {
|
- (void)updateInProgress:(NSNotification*)notify {
|
||||||
[self activateSpinner:[notify.object integerValue]];
|
[self activateSpinner:[notify.object integerValue]];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - Persist state
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
Refresh current context from parent context and start new undo grouping.
|
|
||||||
@note Should be balanced with @c endCoreDataChangeUndoChanges:
|
|
||||||
*/
|
|
||||||
- (void)beginCoreDataChange {
|
|
||||||
// Does seem to create problems with undo stack if refreshing from parent context
|
|
||||||
//[self.dataStore.managedObjectContext refreshAllObjects];
|
|
||||||
[self.undoManager beginUndoGrouping];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
End undo grouping and save changes to persistent store. Or undo group if no changes occured.
|
|
||||||
@note Should be balanced with @c beginCoreDataChange
|
|
||||||
|
|
||||||
@param flag If @c YES force @c NSUndoManager to undo the changes immediatelly.
|
|
||||||
@return Returns @c YES if context was saved.
|
|
||||||
*/
|
|
||||||
- (BOOL)endCoreDataChangeShouldUndo:(BOOL)flag {
|
|
||||||
[self.undoManager endUndoGrouping];
|
|
||||||
if (!flag && self.dataStore.managedObjectContext.hasChanges) {
|
|
||||||
[StoreCoordinator saveContext:self.dataStore.managedObjectContext andParent:YES];
|
|
||||||
[FeedDownload scheduleUpdateForUpcomingFeeds];
|
|
||||||
[self.timerStatusInfo fire];
|
|
||||||
return YES;
|
|
||||||
}
|
|
||||||
[self.undoManager disableUndoRegistration];
|
|
||||||
[self.undoManager undoNestedGroup];
|
|
||||||
[self.undoManager enableUndoRegistration];
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
After the user did undo or redo we can't ensure integrity without doing some additional work.
|
|
||||||
*/
|
|
||||||
- (void)saveWithUnpredictableChange {
|
|
||||||
// dont use unless you merge changes from main
|
|
||||||
// NSManagedObjectContext *moc = self.dataStore.managedObjectContext;
|
|
||||||
// NSPredicate *pred = [NSPredicate predicateWithFormat:@"class == %@", [FeedArticle class]];
|
|
||||||
// NSInteger del = [[[moc.deletedObjects filteredSetUsingPredicate:pred] valueForKeyPath:@"@sum.unread"] integerValue];
|
|
||||||
// NSInteger ins = [[[moc.insertedObjects filteredSetUsingPredicate:pred] valueForKeyPath:@"@sum.unread"] integerValue];
|
|
||||||
// NSLog(@"%ld, %ld", del, ins);
|
|
||||||
[StoreCoordinator saveContext:self.dataStore.managedObjectContext andParent:YES];
|
|
||||||
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
|
|
||||||
[self.dataStore rearrangeObjects]; // update ordering
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - UI Button Interaction
|
#pragma mark - UI Button Interaction
|
||||||
|
|
||||||
|
|
||||||
/// Open clicked or selected item for editing.
|
/// Open clicked or selected item for editing.
|
||||||
- (void)editSelectedItem {
|
- (void)editSelectedItem {
|
||||||
FeedGroup *chosen = [self clickedItem];
|
FeedGroup *chosen = [self userSelectionFirst].representedObject;
|
||||||
if (!chosen) chosen = self.dataStore.selectedObjects.firstObject;
|
|
||||||
[self showModalForFeedGroup:chosen isGroupEdit:YES]; // yes will be overwritten anyway
|
[self showModalForFeedGroup:chosen isGroupEdit:YES]; // yes will be overwritten anyway
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open clicked item for editing.
|
/// Open clicked item for editing.
|
||||||
- (void)doubleClickOutlineView:(NSOutlineView*)sender {
|
- (void)doubleClickOutlineView:(NSOutlineView*)sender {
|
||||||
FeedGroup *fg = [self clickedItem];
|
if (sender.clickedRow != -1) // only if there is a clicked item
|
||||||
if (!fg) return;
|
[self editSelectedItem];
|
||||||
[self showModalForFeedGroup:fg isGroupEdit:YES]; // yes will be overwritten anyway
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add feed button.
|
/// Add feed button.
|
||||||
@@ -245,28 +236,73 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
/// Add separator button.
|
/// Add separator button.
|
||||||
- (void)addSeparator {
|
- (void)addSeparator {
|
||||||
[self beginCoreDataChange];
|
[self beginCoreDataChange];
|
||||||
[self insertFeedGroupAtSelection:SEPARATOR].name = @"---";
|
[self insertFeedGroupAtSelection:SEPARATOR];
|
||||||
[self endCoreDataChangeShouldUndo:NO];
|
[self endCoreDataChangeUndoEmpty:NO forceUndo:NO];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove feed button. User has selected one or more item in outline view.
|
/// Remove feed button. User has selected one or more item in outline view.
|
||||||
- (void)remove:(id)sender {
|
- (void)remove:(id)sender {
|
||||||
|
NSArray<NSTreeNode*> *nodes = [self userSelectionAll];
|
||||||
|
NSArray<NSTreeNode*> *parentNodes = [nodes valueForKeyPath:@"parentNode"];
|
||||||
[self beginCoreDataChange];
|
[self beginCoreDataChange];
|
||||||
NSArray<NSTreeNode*> *parentNodes = [self.dataStore.selectedNodes valueForKeyPath:@"parentNode"];
|
[self.dataStore removeObjectsAtArrangedObjectIndexPaths:[nodes valueForKeyPath:@"indexPath"]];
|
||||||
[self.dataStore remove:sender];
|
[self restoreOrderingAndIndexPathStr:parentNodes];
|
||||||
for (NSTreeNode *parent in [self filterOutRedundant:parentNodes]) {
|
[self endCoreDataChangeUndoEmpty:NO forceUndo:NO];
|
||||||
[self restoreOrderingAndIndexPathStr:parent];
|
[self someDatesChangedScheduleUpdateTimer];
|
||||||
}
|
|
||||||
[self endCoreDataChangeShouldUndo:NO];
|
|
||||||
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
|
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)openImportDialog {
|
- (void)openImportDialog {
|
||||||
[OpmlExport showImportDialog:self.view.window withContext:self.dataStore.managedObjectContext];
|
[[OpmlFileImport withDelegate:self] showImportDialog:self.view.window];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)openExportDialog {
|
- (void)openExportDialog {
|
||||||
[OpmlExport showExportDialog:self.view.window withContext:self.dataStore.managedObjectContext];
|
[[OpmlFileExport withDelegate:self] showExportDialog:self.view.window];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#pragma mark - Keyboard Commands: undo, redo, copy, enter
|
||||||
|
|
||||||
|
|
||||||
|
/// Also look for commands right click menu of outline view
|
||||||
|
- (void)keyDown:(NSEvent *)event {
|
||||||
|
if (![self.view.outline.menu performKeyEquivalent:event]) {
|
||||||
|
[super keyDown:event];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returning @c NO will result in a Action-Not-Available-Buzzer sound
|
||||||
|
- (BOOL)respondsToSelector:(SEL)aSelector {
|
||||||
|
if (aSelector == @selector(undo:))
|
||||||
|
return [self.undoManager canUndo] && self.undoManager.groupingLevel == 0 && ![FeedDownload isUpdating];
|
||||||
|
if (aSelector == @selector(redo:))
|
||||||
|
return [self.undoManager canRedo] && self.undoManager.groupingLevel == 0 && ![FeedDownload isUpdating];
|
||||||
|
if (aSelector == @selector(copy:) || aSelector == @selector(remove:))
|
||||||
|
return ([self userSelectionFirst] != nil);
|
||||||
|
if (aSelector == @selector(editSelectedItem)) {
|
||||||
|
FeedGroup *chosen = [self userSelectionFirst].representedObject;
|
||||||
|
if (chosen && chosen.type != SEPARATOR)
|
||||||
|
return YES; // can edit only if selection is not a separator
|
||||||
|
return NO;
|
||||||
|
}
|
||||||
|
return [super respondsToSelector:aSelector];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform undo operation and redraw UI & menu bar unread count
|
||||||
|
- (void)undo:(id)sender {
|
||||||
|
[self.undoManager undo];
|
||||||
|
[self saveWithUnpredictableChange];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform redo operation and redraw UI & menu bar unread count
|
||||||
|
- (void)redo:(id)sender {
|
||||||
|
[self.undoManager redo];
|
||||||
|
[self saveWithUnpredictableChange];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy human readable description of selected nodes to clipboard.
|
||||||
|
- (void)copy:(id)sender {
|
||||||
|
[[NSPasteboard generalPasteboard] declareTypes:@[NSPasteboardTypeString] owner:self]; // DragDrop handles callback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -293,19 +329,20 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
if (returnCode == NSModalResponseOK) {
|
if (returnCode == NSModalResponseOK) {
|
||||||
[editDialog applyChangesToCoreDataObject];
|
[editDialog applyChangesToCoreDataObject];
|
||||||
}
|
}
|
||||||
if ([self endCoreDataChangeShouldUndo:(returnCode != NSModalResponseOK)]) {
|
if ([self endCoreDataChangeUndoEmpty:YES forceUndo:(returnCode != NSModalResponseOK)]) {
|
||||||
[self.dataStore rearrangeObjects];
|
if (!flag) [self someDatesChangedScheduleUpdateTimer]; // only for feed edit
|
||||||
|
[self.dataStore rearrangeObjects]; // update display, edited title or icon
|
||||||
}
|
}
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert @c FeedGroup item at the end of the current folder (or inside if expanded)
|
/// Insert @c FeedGroup item at the end of the current folder (or inside if expanded)
|
||||||
- (FeedGroup*)insertFeedGroupAtSelection:(FeedGroupType)type {
|
- (FeedGroup*)insertFeedGroupAtSelection:(FeedGroupType)type {
|
||||||
FeedGroup *selObj = self.dataStore.selectedObjects.firstObject;
|
NSTreeNode *selNode = [self userSelectionFirst];
|
||||||
NSTreeNode *selNode = self.dataStore.selectedNodes.firstObject;
|
FeedGroup *selObj = selNode.representedObject;
|
||||||
// If group selected and expanded, insert into group. Else: append at end of current folder
|
// If group selected and expanded, insert into group. Else: append at end of current folder
|
||||||
if (![self.view.outline isItemExpanded:selNode]) {
|
if (![self.view.outline isItemExpanded:selNode]) {
|
||||||
selObj = selObj.parent;
|
selObj = selObj.parent; // nullable
|
||||||
selNode = selNode.parentNode;
|
selNode = selNode.parentNode;
|
||||||
}
|
}
|
||||||
// If no selection, append to root folder
|
// If no selection, append to root folder
|
||||||
@@ -318,81 +355,10 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
return fg;
|
return fg;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loop over all descendants and update @c sortIndex @c (FeedGroup) as well as all @c indexPath @c (Feed)
|
|
||||||
- (void)restoreOrderingAndIndexPathStr:(NSTreeNode*)parent {
|
|
||||||
NSArray<NSTreeNode*> *children = parent.childNodes;
|
|
||||||
for (NSUInteger i = 0; i < children.count; i++) {
|
|
||||||
FeedGroup *fg = [children objectAtIndex:i].representedObject;
|
|
||||||
if (fg.sortIndex != (int32_t)i)
|
|
||||||
fg.sortIndex = (int32_t)i;
|
|
||||||
[fg iterateSorted:NO overDescendantFeeds:^(Feed *feed, BOOL *cancel) {
|
|
||||||
[feed calculateAndSetIndexPathString];
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - Dragging Support, Data Source Delegate
|
|
||||||
|
|
||||||
|
|
||||||
/// Begin drag-n-drop operation by copying selected nodes to memory
|
|
||||||
- (BOOL)outlineView:(NSOutlineView *)outlineView writeItems:(NSArray *)items toPasteboard:(NSPasteboard *)pboard {
|
|
||||||
[self beginCoreDataChange];
|
|
||||||
[pboard declareTypes:[NSArray arrayWithObject:dragNodeType] owner:self];
|
|
||||||
[pboard setString:@"dragging" forType:dragNodeType];
|
|
||||||
self.currentlyDraggedNodes = items;
|
|
||||||
return YES;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Finish drag-n-drop operation by saving changes to persistent store
|
|
||||||
- (void)outlineView:(NSOutlineView *)outlineView draggingSession:(NSDraggingSession *)session endedAtPoint:(NSPoint)screenPoint operation:(NSDragOperation)operation {
|
|
||||||
[self endCoreDataChangeShouldUndo:NO];
|
|
||||||
self.currentlyDraggedNodes = nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Perform drag-n-drop operation, move nodes to new destination and update all indices
|
|
||||||
- (BOOL)outlineView:(NSOutlineView *)outlineView acceptDrop:(id <NSDraggingInfo>)info item:(id)item childIndex:(NSInteger)index {
|
|
||||||
NSTreeNode *destParent = (item != nil ? item : [self.dataStore arrangedObjects]);
|
|
||||||
NSUInteger idx = (NSUInteger)index;
|
|
||||||
if (index == -1) // drag items on folder or root drop
|
|
||||||
idx = destParent.childNodes.count;
|
|
||||||
|
|
||||||
NSArray<NSTreeNode*> *previousParents = [self.currentlyDraggedNodes valueForKeyPath:@"parentNode"];
|
|
||||||
[self.dataStore moveNodes:self.currentlyDraggedNodes toIndexPath:[destParent.indexPath indexPathByAddingIndex:idx]];
|
|
||||||
|
|
||||||
for (NSTreeNode *node in [self filterOutRedundant:[previousParents arrayByAddingObject:destParent]]) {
|
|
||||||
[self restoreOrderingAndIndexPathStr:node];
|
|
||||||
}
|
|
||||||
|
|
||||||
return YES;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate method whether items can be dropped at destination
|
|
||||||
- (NSDragOperation)outlineView:(NSOutlineView *)outlineView validateDrop:(id <NSDraggingInfo>)info proposedItem:(id)item proposedChildIndex:(NSInteger)index {
|
|
||||||
NSTreeNode *parent = item;
|
|
||||||
if (index == -1 && [parent isLeaf]) { // if drag is on specific item and that item isnt a group
|
|
||||||
return NSDragOperationNone;
|
|
||||||
}
|
|
||||||
while (parent != nil) {
|
|
||||||
for (NSTreeNode *node in self.currentlyDraggedNodes) {
|
|
||||||
if (parent == node)
|
|
||||||
return NSDragOperationNone; // cannot move items into a child of its own
|
|
||||||
}
|
|
||||||
parent = [parent parentNode];
|
|
||||||
}
|
|
||||||
return NSDragOperationGeneric;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - Data Source Delegate
|
#pragma mark - Data Source Delegate
|
||||||
|
|
||||||
|
|
||||||
// Data source is handled by bindings anyway. These methods can be ignored
|
|
||||||
- (NSInteger)outlineView:(NSOutlineView *)outlineView numberOfChildrenOfItem:(id)item { return 0; }
|
|
||||||
- (BOOL)outlineView:(NSOutlineView *)outlineView isItemExpandable:(id)item { return YES; }
|
|
||||||
- (id)outlineView:(NSOutlineView *)outlineView child:(NSInteger)index ofItem:(id)item { return nil; }
|
|
||||||
- (id)outlineView:(NSOutlineView *)outlineView objectValueForTableColumn:(NSTableColumn *)tableColumn byItem:(id)item { return nil; }
|
|
||||||
|
|
||||||
/// Populate @c NSOutlineView data cells with core data object values.
|
/// Populate @c NSOutlineView data cells with core data object values.
|
||||||
- (NSView *)outlineView:(NSOutlineView *)outlineView viewForTableColumn:(NSTableColumn *)tableColumn item:(NSTreeNode*)item {
|
- (NSView *)outlineView:(NSOutlineView *)outlineView viewForTableColumn:(NSTableColumn *)tableColumn item:(NSTreeNode*)item {
|
||||||
NSUserInterfaceItemIdentifier ident = tableColumn.identifier;
|
NSUserInterfaceItemIdentifier ident = tableColumn.identifier;
|
||||||
@@ -409,93 +375,38 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
|||||||
return nil;
|
return nil;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @return User clicked cell item or @c nil if user did not click on a cell.
|
|
||||||
- (FeedGroup*)clickedItem {
|
|
||||||
NSOutlineView *ov = self.view.outline;
|
|
||||||
return [(NSTreeNode*)[ov itemAtRow:ov.clickedRow] representedObject];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
#pragma mark - Helper Methods
|
||||||
|
|
||||||
#pragma mark - Keyboard Commands: undo, redo, copy, enter
|
|
||||||
|
|
||||||
|
|
||||||
/// Also look for commands right click menu of outline view
|
|
||||||
- (void)keyDown:(NSEvent *)event {
|
|
||||||
if (![self.view.outline.menu performKeyEquivalent:event]) {
|
|
||||||
[super keyDown:event];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returning @c NO will result in a Action-Not-Available-Buzzer sound
|
|
||||||
- (BOOL)respondsToSelector:(SEL)aSelector {
|
|
||||||
if (aSelector == @selector(undo:))
|
|
||||||
return [self.undoManager canUndo] && self.undoManager.groupingLevel == 0 && ![FeedDownload isUpdating];
|
|
||||||
if (aSelector == @selector(redo:))
|
|
||||||
return [self.undoManager canRedo] && self.undoManager.groupingLevel == 0 && ![FeedDownload isUpdating];
|
|
||||||
if (aSelector == @selector(copy:) || aSelector == @selector(remove:))
|
|
||||||
return self.dataStore.selectedNodes.count > 0;
|
|
||||||
if (aSelector == @selector(editSelectedItem)) {
|
|
||||||
FeedGroup *chosen = [self clickedItem];
|
|
||||||
if (!chosen) chosen = self.dataStore.selectedObjects.firstObject;
|
|
||||||
if (chosen && chosen.type != SEPARATOR)
|
|
||||||
return YES; // can edit only if selection is not a separator
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
return [super respondsToSelector:aSelector];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Perform undo operation and redraw UI & menu bar unread count
|
|
||||||
- (void)undo:(id)sender {
|
|
||||||
[self.undoManager undo];
|
|
||||||
[self saveWithUnpredictableChange];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Perform redo operation and redraw UI & menu bar unread count
|
|
||||||
- (void)redo:(id)sender {
|
|
||||||
[self.undoManager redo];
|
|
||||||
[self saveWithUnpredictableChange];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Copy human readable description of selected nodes to clipboard.
|
|
||||||
- (void)copy:(id)sender {
|
|
||||||
NSMutableString *str = [[NSMutableString alloc] init];
|
|
||||||
for (NSTreeNode *node in [self filterOutRedundant:self.dataStore.selectedNodes]) {
|
|
||||||
[self traverseChildren:node appendString:str prefix:@""];
|
|
||||||
}
|
|
||||||
[[NSPasteboard generalPasteboard] clearContents];
|
|
||||||
[[NSPasteboard generalPasteboard] setString:str forType:NSPasteboardTypeString];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Go through all children recursively and prepend the string with spaces as nesting
|
Expected user selection as displayed in outline (border highlight).
|
||||||
@param obj Root Node or parent Node
|
Return clicked row only if it isn't included in the selection.
|
||||||
@param str An initialized @c NSMutableString to append to
|
|
||||||
@param prefix Should be @c @@"" for the first call
|
|
||||||
*/
|
*/
|
||||||
- (void)traverseChildren:(NSTreeNode*)obj appendString:(NSMutableString*)str prefix:(NSString*)prefix {
|
- (NSArray<NSTreeNode*>*)userSelectionAll {
|
||||||
[str appendFormat:@"%@%@\n", prefix, [obj.representedObject readableDescription]];
|
NSOutlineView *ov = self.view.outline;
|
||||||
prefix = [prefix stringByAppendingString:@" "];
|
NSTreeNode *clicked = [ov itemAtRow: ov.clickedRow];
|
||||||
for (NSTreeNode *child in obj.childNodes) {
|
if (!clicked || [self.dataStore.selectedNodes containsObject:clicked]) {
|
||||||
[self traverseChildren:child appendString:str prefix:prefix];
|
return self.dataStore.selectedNodes;
|
||||||
}
|
}
|
||||||
|
return @[clicked];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove redundant nodes that are already present in some selected parent node
|
/// Return clicked row (if present) or first selected node otherwise.
|
||||||
- (NSArray<NSTreeNode*>*)filterOutRedundant:(NSArray<NSTreeNode*>*)nodes {
|
- (NSTreeNode*)userSelectionFirst {
|
||||||
NSMutableArray<NSTreeNode*> *result = [NSMutableArray arrayWithCapacity:nodes.count];
|
NSTreeNode *clicked = [self.view.outline itemAtRow: self.view.outline.clickedRow];
|
||||||
for (NSTreeNode *current in nodes) {
|
if (clicked) return clicked;
|
||||||
BOOL skip = NO;
|
return self.dataStore.selectedNodes.firstObject;
|
||||||
for (NSTreeNode *stored in result) {
|
}
|
||||||
NSIndexPath *p = current.indexPath;
|
|
||||||
while (p.length > stored.indexPath.length)
|
/// Loop over all descendants and update @c sortIndex @c (FeedGroup) as well as all @c indexPath @c (Feed)
|
||||||
p = [p indexPathByRemovingLastIndex];
|
- (void)restoreOrderingAndIndexPathStr:(NSArray<NSTreeNode*>*)parentsList {
|
||||||
if ([p isEqualTo:stored.indexPath]) {
|
for (NSTreeNode *parent in parentsList) {
|
||||||
skip = YES; break;
|
for (NSUInteger i = 0; i < parent.childNodes.count; i++) {
|
||||||
}
|
FeedGroup *fg = parent.childNodes[i].representedObject;
|
||||||
|
[fg setSortIndexIfChanged:(int32_t)i];
|
||||||
}
|
}
|
||||||
if (skip == NO) [result addObject:current];
|
|
||||||
}
|
}
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -72,8 +72,6 @@
|
|||||||
|
|
||||||
// Setup action and bindings
|
// Setup action and bindings
|
||||||
SettingsFeeds *sf = self.controller;
|
SettingsFeeds *sf = self.controller;
|
||||||
o.delegate = sf;
|
|
||||||
o.dataSource = sf;
|
|
||||||
o.target = sf;
|
o.target = sf;
|
||||||
o.doubleAction = @selector(doubleClickOutlineView:);
|
o.doubleAction = @selector(doubleClickOutlineView:);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user