From d56916be7a68286ad4c6157732aab15fbe62a4a7 Mon Sep 17 00:00:00 2001 From: relikd Date: Thu, 25 Jul 2019 16:50:58 +0200 Subject: [PATCH] Drag & drop support for OPML files --- CHANGELOG.md | 19 +- baRSS.xcodeproj/project.pbxproj | 10 +- baRSS/Constants.h | 15 +- baRSS/Core Data/FeedGroup+Ext.h | 2 +- baRSS/Core Data/FeedGroup+Ext.m | 18 +- baRSS/Info.plist | 32 +- baRSS/Preferences/Feeds Tab/OpmlExport.h | 42 +- baRSS/Preferences/Feeds Tab/OpmlExport.m | 332 ++++++++------ .../Feeds Tab/SettingsFeeds+DragDrop.h | 28 ++ .../Feeds Tab/SettingsFeeds+DragDrop.m | 245 ++++++++++ baRSS/Preferences/Feeds Tab/SettingsFeeds.h | 8 +- baRSS/Preferences/Feeds Tab/SettingsFeeds.m | 433 +++++++----------- .../Preferences/Feeds Tab/SettingsFeedsView.m | 2 - 13 files changed, 749 insertions(+), 437 deletions(-) create mode 100644 baRSS/Preferences/Feeds Tab/SettingsFeeds+DragDrop.h create mode 100644 baRSS/Preferences/Feeds Tab/SettingsFeeds+DragDrop.m diff --git a/CHANGELOG.md b/CHANGELOG.md index c47596e..2c0540e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,23 +7,28 @@ and this project does NOT adhere to [Semantic Versioning](https://semver.org/spe ## [Unreleased] ### Added -- Show users any 5xx server error response and extracted failure reason -- 5xx server errors have a reload button which will initiate a new download with the same URL +- Adding feed: 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 - 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 -- Changed error message text when user cancels creation of new feed item -- Comparing existing articles with nonexistent guid and link +- Adding feed: Show users any 5xx server error response and extracted failure reason - 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 ### Changed - Interface builder files replaced with code equivalent - Settings, Feeds: Single add button for feeds, groups, and separators -- Refresh interval hotkeys set to: Cmd+1 … Cmd+6 -- Always append new items at the end +- Settings, Feeds: 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 diff --git a/baRSS.xcodeproj/project.pbxproj b/baRSS.xcodeproj/project.pbxproj index b7ce640..5134530 100644 --- a/baRSS.xcodeproj/project.pbxproj +++ b/baRSS.xcodeproj/project.pbxproj @@ -36,6 +36,7 @@ 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 */; }; 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 */; }; 54E88320211B509D00064188 /* ModalFeedEdit.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E8831E211B509D00064188 /* ModalFeedEdit.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 = ""; }; 54BB048821FD2AB500C303A5 /* NSDate+Ext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSDate+Ext.m"; sourceTree = ""; }; 54BF444922D0F4F300660096 /* AppIcon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = AppIcon.icns; sourceTree = ""; }; + 54D55D7122E624CD00057B98 /* SettingsFeeds+DragDrop.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SettingsFeeds+DragDrop.h"; sourceTree = ""; }; + 54D55D7222E624CD00057B98 /* SettingsFeeds+DragDrop.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "SettingsFeeds+DragDrop.m"; sourceTree = ""; }; 54D857CC227C5785001BA1C8 /* RefreshStatisticsView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RefreshStatisticsView.h; sourceTree = ""; }; 54D857CD227C5785001BA1C8 /* RefreshStatisticsView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RefreshStatisticsView.m; sourceTree = ""; }; 54D857D022802309001BA1C8 /* SettingsGeneralView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SettingsGeneralView.h; sourceTree = ""; }; @@ -301,6 +304,10 @@ children = ( 546FC43B21188AD5007CC3A3 /* SettingsFeeds.h */, 546FC43C21188AD5007CC3A3 /* SettingsFeeds.m */, + 54D55D7122E624CD00057B98 /* SettingsFeeds+DragDrop.h */, + 54D55D7222E624CD00057B98 /* SettingsFeeds+DragDrop.m */, + 54F6025B21C1D4170006D338 /* OpmlExport.h */, + 54F6025C21C1D4170006D338 /* OpmlExport.m */, 5478DF02225A7AE200D30C64 /* SettingsFeedsView.h */, 5478DF03225A7AE200D30C64 /* SettingsFeedsView.m */, 54E8831D211B509D00064188 /* ModalFeedEdit.h */, @@ -309,8 +316,6 @@ 54B51703226DC339006C1B29 /* ModalFeedEditView.m */, 54D857CC227C5785001BA1C8 /* RefreshStatisticsView.h */, 54D857CD227C5785001BA1C8 /* RefreshStatisticsView.m */, - 54F6025B21C1D4170006D338 /* OpmlExport.h */, - 54F6025C21C1D4170006D338 /* OpmlExport.m */, ); path = "Feeds Tab"; sourceTree = ""; @@ -430,6 +435,7 @@ 546A6A2C22C584AF0034E806 /* SettingsAppearanceView.m in Sources */, 54E9CF32225914300023696F /* SettingsAbout.m in Sources */, 54B749E0220636200022CC6D /* FeedArticle+Ext.m in Sources */, + 54D55D7322E624CD00057B98 /* SettingsFeeds+DragDrop.m in Sources */, 54F39C2E210BE1F700AEE730 /* DBv1.xcdatamodeld in Sources */, 544B011D2114EE9100386E5C /* AppHook.m in Sources */, 546FC44321189975007CC3A3 /* SettingsGeneral.m in Sources */, diff --git a/baRSS/Constants.h b/baRSS/Constants.h index fb5a37b..af9a07e 100644 --- a/baRSS/Constants.h +++ b/baRSS/Constants.h @@ -28,37 +28,40 @@ // 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. 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. 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. 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 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. 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. If new count is known an absoulte number is passed. 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"; /** diff --git a/baRSS/Core Data/FeedGroup+Ext.h b/baRSS/Core Data/FeedGroup+Ext.h index ddf41af..509f432 100644 --- a/baRSS/Core Data/FeedGroup+Ext.h +++ b/baRSS/Core Data/FeedGroup+Ext.h @@ -39,13 +39,13 @@ typedef NS_ENUM(int16_t, FeedGroupType) { + (instancetype)newGroup:(FeedGroupType)type inContext:(NSManagedObjectContext*)context; - (void)setParent:(FeedGroup *)parent andSortIndex:(int32_t)sortIndex; +- (void)setSortIndexIfChanged:(int32_t)sortIndex; - (void)setNameIfChanged:(NSString*)name; - (NSMenuItem*)newMenuItem; // Handle children and parents - (NSString*)indexPathString; - (NSArray*)sortedChildren; - (NSMutableArray*)allParents; -- (BOOL)iterateSorted:(BOOL)ordered overDescendantFeeds:(void(^)(Feed *feed, BOOL* cancel))block; // Printing - (NSString*)readableDescription; @end diff --git a/baRSS/Core Data/FeedGroup+Ext.m b/baRSS/Core Data/FeedGroup+Ext.m index 4bef00e..3daaa97 100644 --- a/baRSS/Core Data/FeedGroup+Ext.m +++ b/baRSS/Core Data/FeedGroup+Ext.m @@ -58,11 +58,15 @@ + (instancetype)newGroup:(FeedGroupType)type inContext:(NSManagedObjectContext*)moc { FeedGroup *fg = [[FeedGroup alloc] initWithEntity: FeedGroup.entity insertIntoManagedObjectContext:moc]; fg.type = type; - if (type == FEED) - fg.feed = [Feed newFeedAndMetaInContext:moc]; + switch (type) { + case GROUP: break; + case FEED: fg.feed = [Feed newFeedAndMetaInContext:moc]; break; + case SEPARATOR: fg.name = @"---"; break; + } return fg; } +/// Set @c parent and @c sortIndex. Also if type is @c FEED calculate and set @c indexPath string. - (void)setParent:(FeedGroup *)parent andSortIndex:(int32_t)sortIndex { self.parent = parent; self.sortIndex = sortIndex; @@ -70,6 +74,16 @@ [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. - (void)setNameIfChanged:(NSString*)name { if (![self.name isEqualToString: name]) diff --git a/baRSS/Info.plist b/baRSS/Info.plist index 58e4242..41ccd0c 100644 --- a/baRSS/Info.plist +++ b/baRSS/Info.plist @@ -32,7 +32,7 @@ CFBundleVersion - 8048 + 9512 LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) LSUIElement @@ -46,5 +46,35 @@ Copyright © 2019 relikd. Public Domain. NSPrincipalClass AppHook + UTImportedTypeDeclarations + + + UTTypeConformsTo + + public.xml + + UTTypeDescription + OPML file + UTTypeIconFile + AppIcon + UTTypeIdentifier + org.opml + UTTypeReferenceURL + http://dev.opml.org/spec2.html + UTTypeTagSpecification + + com.apple.ostype + + public.filename-extension + + opml + + public.mime-type + + text/xml + + + + diff --git a/baRSS/Preferences/Feeds Tab/OpmlExport.h b/baRSS/Preferences/Feeds Tab/OpmlExport.h index 49e0905..ff6dfe8 100644 --- a/baRSS/Preferences/Feeds Tab/OpmlExport.h +++ b/baRSS/Preferences/Feeds Tab/OpmlExport.h @@ -23,9 +23,43 @@ #import #import -@class Feed; +@class FeedGroup; -@interface OpmlExport : NSObject -+ (void)showImportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc; -+ (void)showExportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc; +typedef NS_OPTIONS(NSUInteger, OpmlFileExportOptions) { + OpmlFileExportOptionFlattened = 1 << 1, + OpmlFileExportOptionFullBackup = 1 << 2, +}; + +#pragma mark - Protocols + +@protocol OpmlFileImportDelegate +@required +- (NSManagedObjectContext*)opmlFileImportContext; // currently called only once +@optional +- (void)opmlFileImportWillBegin:(NSManagedObjectContext*)moc; +- (void)opmlFileImportDidEnd:(NSManagedObjectContext*)moc; +@end + + +@protocol OpmlFileExportDelegate +@required +- (NSArray*)opmlFileExportListOfFeedGroups:(OpmlFileExportOptions)options; +@end + + +#pragma mark - Classes + +@interface OpmlFileImport : NSObject +@property (weak) id delegate; ++ (instancetype)withDelegate:(id)delegate; +- (void)showImportDialog:(NSWindow*)window; +- (void)importFiles:(NSArray*)files; +@end + + +@interface OpmlFileExport : NSObject +@property (weak) id delegate; ++ (instancetype)withDelegate:(id)delegate; +- (void)showExportDialog:(NSWindow*)window; +- (nullable NSError*)writeOPMLFile:(NSURL*)url withOptions:(OpmlFileExportOptions)opt; @end diff --git a/baRSS/Preferences/Feeds Tab/OpmlExport.m b/baRSS/Preferences/Feeds Tab/OpmlExport.m index 7cf5824..c5fc0f5 100644 --- a/baRSS/Preferences/Feeds Tab/OpmlExport.m +++ b/baRSS/Preferences/Feeds Tab/OpmlExport.m @@ -29,125 +29,99 @@ #import "NSDate+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. -+ (void)showImportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc { - NSOpenPanel *op = [NSOpenPanel openPanel]; - op.allowedFileTypes = @[@"opml"]; - [op beginSheetModalForWindow:window completionHandler:^(NSModalResponse result) { - 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]; - } - }]; +/// Loop over all subviews and find the @c NSButton that is selected. +NS_INLINE NSInteger RadioGroupSelection(NSView *view) { + for (NSButton *btn in view.subviews) { + if ([btn isKindOfClass:[NSButton class]] && btn.state == NSControlStateValueOn) { + return btn.tag; } - }]; -} - -/// 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 *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]; - } - } - }]; + } + return -1; } +// ################################################################ +// # +// # OPML Import +// # +// ################################################################ #pragma mark - Import +@implementation OpmlFileImport -/** - 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 [self radioGroupSelection:alert.accessoryView]; - } - return -1; // cancel button ++ (instancetype)withDelegate:(id)delegate { + OpmlFileImport *opml = [[super alloc] init]; + opml.delegate = delegate; + return opml; } -/** - Perform import of @c FeedGroup items. - */ -+ (void)importOPMLDocument:(RSOPMLItem*)doc inContext:(NSManagedObjectContext*)moc { - NSInteger select = [self askToAppendOrOverwriteAlert:doc inContext:moc]; - if (select < 0 || select > 1) // not a valid selection (or cancel button) - return; - - [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 +/// Display Open File Panel to select @c .opml file. Perform web requests (feed data & icon) within a single undo group. +- (void)showImportDialog:(NSWindow*)window { + NSOpenPanel *op = [NSOpenPanel openPanel]; + op.allowedFileTypes = @[UTI_OPML]; + op.allowsMultipleSelection = YES; + [op beginSheetModalForWindow:window completionHandler:^(NSModalResponse result) { + if (result == NSModalResponseOK) { + [self importFiles:op.URLs]; } - [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:@0]; - } else { - idx = (int32_t)[StoreCoordinator countRootItemsInContext:moc]; - } - - NSMutableArray *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*)files { + id 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*)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. - + @param item The item to be imported. @param parent The already processed parent item. @param idx @c sortIndex within the @c parent item. @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 *)list { +- (void)importFeed:(RSOPMLItem*)item parent:(FeedGroup*)parent index:(int32_t)idx inContext:(NSManagedObjectContext*)moc { FeedGroupType type = GROUP; if ([item attributeForKey:OPMLXMLURLKey]) { type = FEED; @@ -157,44 +131,129 @@ FeedGroup *newFeed = [FeedGroup newGroup:type inContext:moc]; [newFeed setParent:parent andSortIndex:idx]; - newFeed.name = (type == SEPARATOR ? @"---" : item.displayName); - switch (type) { - case GROUP: - 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: - @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: - break; + if (type == SEPARATOR) + return; + + newFeed.name = item.displayName; + + if (type == FEED) { + 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 +@implementation OpmlFileExport + ++ (instancetype)withDelegate:(id)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 *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. @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*)list hierarchical:(BOOL)flag { +- (NSXMLDocument*)xmlDocumentForFeeds:(NSArray*)list hierarchical:(BOOL)flag { NSXMLElement *head = [NSXMLElement elementWithName:@"head"]; head.children = @[[NSXMLElement elementWithName:@"title" stringValue:@"baRSS feeds"], [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. */ -+ (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) { // dont add group node if hierarchical == NO 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 diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeeds+DragDrop.h b/baRSS/Preferences/Feeds Tab/SettingsFeeds+DragDrop.h new file mode 100644 index 0000000..110530c --- /dev/null +++ b/baRSS/Preferences/Feeds Tab/SettingsFeeds+DragDrop.h @@ -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) +- (void)prepareOutlineViewForDragDrop:(NSOutlineView*)outline; +@end diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeeds+DragDrop.m b/baRSS/Preferences/Feeds Tab/SettingsFeeds+DragDrop.m new file mode 100644 index 0000000..243d01c --- /dev/null +++ b/baRSS/Preferences/Feeds Tab/SettingsFeeds+DragDrop.m @@ -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 )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 )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 *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 *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 )info { + if ([info.draggingPasteboard canReadItemWithDataConformingToTypes:@[(NSPasteboardType)kUTTypeFileURL]]) { + NSDraggingItemEnumerationOptions opt = NSDraggingItemEnumerationClearNonenumeratedImages; + NSArray *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 *selection = [NSMutableArray arrayWithCapacity:count]; + NSMutableArray *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*)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*)draggedTopLevelNodes { + NSArray *nodes = self.currentlyDraggedNodes; + if (!nodes) nodes = self.dataStore.selectedNodes; // fallback to selection (e.g., Cmd-C) + NSMutableArray *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 diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeeds.h b/baRSS/Preferences/Feeds Tab/SettingsFeeds.h index 92432f0..aefaa96 100644 --- a/baRSS/Preferences/Feeds Tab/SettingsFeeds.h +++ b/baRSS/Preferences/Feeds Tab/SettingsFeeds.h @@ -23,8 +23,9 @@ #import /** Manages the NSOutlineView and Feed creation and editing */ -@interface SettingsFeeds : NSViewController +@interface SettingsFeeds : NSViewController @property (strong) NSTreeController *dataStore; +@property (strong) NSArray *currentlyDraggedNodes; - (void)editSelectedItem; - (void)doubleClickOutlineView:(NSOutlineView*)sender; @@ -34,4 +35,9 @@ - (void)remove:(id)sender; - (void)openImportDialog; - (void)openExportDialog; + +- (void)beginCoreDataChange; +- (BOOL)endCoreDataChangeUndoEmpty:(BOOL)undoEmpty forceUndo:(BOOL)force; +- (void)someDatesChangedScheduleUpdateTimer; +- (void)restoreOrderingAndIndexPathStr:(NSArray*)parentsList; @end diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeeds.m b/baRSS/Preferences/Feeds Tab/SettingsFeeds.m index c4b83ee..a5fda42 100644 --- a/baRSS/Preferences/Feeds Tab/SettingsFeeds.m +++ b/baRSS/Preferences/Feeds Tab/SettingsFeeds.m @@ -20,37 +20,29 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -#import "SettingsFeeds.h" +#import "SettingsFeeds+DragDrop.h" #import "Constants.h" #import "StoreCoordinator.h" #import "ModalFeedEdit.h" -#import "Feed+Ext.h" #import "FeedGroup+Ext.h" -#import "OpmlExport.h" #import "FeedDownload.h" #import "SettingsFeedsView.h" #import "NSDate+Ext.h" @interface SettingsFeeds () @property (strong) SettingsFeedsView *view; // override super - -@property (strong) NSArray *currentlyDraggedNodes; @property (strong) NSUndoManager *undoManager; - @property (strong) NSTimer *timerStatusInfo; @end @implementation SettingsFeeds @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 { [self initCoreDataStore]; 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 { @@ -65,6 +57,28 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; [[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 { self.undoManager = [[NSUndoManager alloc] init]; 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 -/// 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. - (void)keepTimerRunning { 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. - (void)updateInProgress:(NSNotification*)notify { [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 /// Open clicked or selected item for editing. - (void)editSelectedItem { - FeedGroup *chosen = [self clickedItem]; - if (!chosen) chosen = self.dataStore.selectedObjects.firstObject; + FeedGroup *chosen = [self userSelectionFirst].representedObject; [self showModalForFeedGroup:chosen isGroupEdit:YES]; // yes will be overwritten anyway } /// Open clicked item for editing. - (void)doubleClickOutlineView:(NSOutlineView*)sender { - FeedGroup *fg = [self clickedItem]; - if (!fg) return; - [self showModalForFeedGroup:fg isGroupEdit:YES]; // yes will be overwritten anyway + if (sender.clickedRow != -1) // only if there is a clicked item + [self editSelectedItem]; } /// Add feed button. @@ -245,28 +236,73 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; /// Add separator button. - (void)addSeparator { [self beginCoreDataChange]; - [self insertFeedGroupAtSelection:SEPARATOR].name = @"---"; - [self endCoreDataChangeShouldUndo:NO]; + [self insertFeedGroupAtSelection:SEPARATOR]; + [self endCoreDataChangeUndoEmpty:NO forceUndo:NO]; } /// Remove feed button. User has selected one or more item in outline view. - (void)remove:(id)sender { + NSArray *nodes = [self userSelectionAll]; + NSArray *parentNodes = [nodes valueForKeyPath:@"parentNode"]; [self beginCoreDataChange]; - NSArray *parentNodes = [self.dataStore.selectedNodes valueForKeyPath:@"parentNode"]; - [self.dataStore remove:sender]; - for (NSTreeNode *parent in [self filterOutRedundant:parentNodes]) { - [self restoreOrderingAndIndexPathStr:parent]; - } - [self endCoreDataChangeShouldUndo:NO]; + [self.dataStore removeObjectsAtArrangedObjectIndexPaths:[nodes valueForKeyPath:@"indexPath"]]; + [self restoreOrderingAndIndexPathStr:parentNodes]; + [self endCoreDataChangeUndoEmpty:NO forceUndo:NO]; + [self someDatesChangedScheduleUpdateTimer]; [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil]; } - (void)openImportDialog { - [OpmlExport showImportDialog:self.view.window withContext:self.dataStore.managedObjectContext]; + [[OpmlFileImport withDelegate:self] showImportDialog:self.view.window]; } - (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) { [editDialog applyChangesToCoreDataObject]; } - if ([self endCoreDataChangeShouldUndo:(returnCode != NSModalResponseOK)]) { - [self.dataStore rearrangeObjects]; + if ([self endCoreDataChangeUndoEmpty:YES forceUndo:(returnCode != NSModalResponseOK)]) { + 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) - (FeedGroup*)insertFeedGroupAtSelection:(FeedGroupType)type { - FeedGroup *selObj = self.dataStore.selectedObjects.firstObject; - NSTreeNode *selNode = self.dataStore.selectedNodes.firstObject; + NSTreeNode *selNode = [self userSelectionFirst]; + FeedGroup *selObj = selNode.representedObject; // If group selected and expanded, insert into group. Else: append at end of current folder if (![self.view.outline isItemExpanded:selNode]) { - selObj = selObj.parent; + selObj = selObj.parent; // nullable selNode = selNode.parentNode; } // If no selection, append to root folder @@ -318,81 +355,10 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; 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 *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 )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 *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 )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 -// 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. - (NSView *)outlineView:(NSOutlineView *)outlineView viewForTableColumn:(NSTableColumn *)tableColumn item:(NSTreeNode*)item { NSUserInterfaceItemIdentifier ident = tableColumn.identifier; @@ -409,93 +375,38 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; 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 - @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 + Expected user selection as displayed in outline (border highlight). + Return clicked row only if it isn't included in the selection. */ -- (void)traverseChildren:(NSTreeNode*)obj appendString:(NSMutableString*)str prefix:(NSString*)prefix { - [str appendFormat:@"%@%@\n", prefix, [obj.representedObject readableDescription]]; - prefix = [prefix stringByAppendingString:@" "]; - for (NSTreeNode *child in obj.childNodes) { - [self traverseChildren:child appendString:str prefix:prefix]; +- (NSArray*)userSelectionAll { + NSOutlineView *ov = self.view.outline; + NSTreeNode *clicked = [ov itemAtRow: ov.clickedRow]; + if (!clicked || [self.dataStore.selectedNodes containsObject:clicked]) { + return self.dataStore.selectedNodes; } + return @[clicked]; } -/// Remove redundant nodes that are already present in some selected parent node -- (NSArray*)filterOutRedundant:(NSArray*)nodes { - NSMutableArray *result = [NSMutableArray arrayWithCapacity:nodes.count]; - for (NSTreeNode *current in nodes) { - BOOL skip = NO; - for (NSTreeNode *stored in result) { - NSIndexPath *p = current.indexPath; - while (p.length > stored.indexPath.length) - p = [p indexPathByRemovingLastIndex]; - if ([p isEqualTo:stored.indexPath]) { - skip = YES; break; - } +/// Return clicked row (if present) or first selected node otherwise. +- (NSTreeNode*)userSelectionFirst { + NSTreeNode *clicked = [self.view.outline itemAtRow: self.view.outline.clickedRow]; + if (clicked) return clicked; + return self.dataStore.selectedNodes.firstObject; +} + +/// Loop over all descendants and update @c sortIndex @c (FeedGroup) as well as all @c indexPath @c (Feed) +- (void)restoreOrderingAndIndexPathStr:(NSArray*)parentsList { + for (NSTreeNode *parent in parentsList) { + 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 diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeedsView.m b/baRSS/Preferences/Feeds Tab/SettingsFeedsView.m index bc422fd..39de9ec 100644 --- a/baRSS/Preferences/Feeds Tab/SettingsFeedsView.m +++ b/baRSS/Preferences/Feeds Tab/SettingsFeedsView.m @@ -72,8 +72,6 @@ // Setup action and bindings SettingsFeeds *sf = self.controller; - o.delegate = sf; - o.dataSource = sf; o.target = sf; o.doubleAction = @selector(doubleClickOutlineView:);