Drag & drop support for OPML files

This commit is contained in:
relikd
2019-07-25 16:50:58 +02:00
parent 85cc12f34a
commit d56916be7a
13 changed files with 749 additions and 437 deletions

View File

@@ -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

View File

@@ -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 */,

View File

@@ -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";
/** /**

View File

@@ -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

View File

@@ -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])

View File

@@ -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>

View File

@@ -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

View File

@@ -29,115 +29,90 @@
#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.
@@ -145,9 +120,8 @@
@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];
}
break;
case FEED: newFeed.name = item.displayName;
@autoreleasepool {
FeedMeta *meta = newFeed.feed.meta;
meta.url = [item attributeForKey:OPMLXMLURLKey];
id refresh = [item attributeForKey:@"refreshInterval"]; // baRSS specific
if (refresh) {
[meta setRefreshAndSchedule:(int32_t)[refresh integerValue]];
} else {
[meta setRefreshAndSchedule:kDefaultFeedRefreshInterval]; // TODO: set -1, then auto
}
}
[list addObject:newFeed.feed];
break;
case SEPARATOR: if (type == FEED) {
break; id refresh = [item attributeForKey:@"refreshInterval"]; // baRSS specific
int32_t interval = kDefaultFeedRefreshInterval; // TODO: set -1, then auto
if (refresh)
interval = (int32_t)[refresh integerValue];
newFeed.feed.meta.url = [item attributeForKey:OPMLXMLURLKey];
[newFeed.feed.meta setRefreshAndSchedule:interval];
} else { // GROUP
for (NSUInteger i = 0; i < item.children.count; i++) {
[self importFeed:item.children[i] parent:newFeed index:(int32_t)i inContext:moc];
}
} }
} }
/**
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

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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:);