// // The MIT License (MIT) // Copyright (c) 2018 Oleg Geier // // Permission is hereby granted, free of charge, to any person obtaining a copy of // this software and associated documentation files (the "Software"), to deal in // the Software without restriction, including without limitation the rights to // use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies // of the Software, and to permit persons to whom the Software is furnished to do // so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. #import "SettingsFeeds.h" #import "Constants.h" #import "StoreCoordinator.h" #import "ModalFeedEdit.h" #import "Feed+Ext.h" #import "FeedGroup+Ext.h" #import "OpmlExport.h" @interface SettingsFeeds () @property (weak) IBOutlet NSOutlineView *outlineView; @property (weak) IBOutlet NSTreeController *dataStore; @property (strong) NSArray *currentlyDraggedNodes; @property (strong) NSUndoManager *undoManager; @end @implementation SettingsFeeds // Declare a string constant for the drag type - to be used when writing and retrieving pasteboard data... static NSString *dragNodeType = @"baRSS-feed-drag"; - (void)viewDidLoad { [super viewDidLoad]; [self.outlineView registerForDraggedTypes:[NSArray arrayWithObject:dragNodeType]]; [self.dataStore setSortDescriptors:[NSArray arrayWithObject:[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]]]; self.undoManager = [[NSUndoManager alloc] init]; self.undoManager.groupsByEvent = NO; self.undoManager.levelsOfUndo = 30; self.dataStore.managedObjectContext = [StoreCoordinator createChildContext]; self.dataStore.managedObjectContext.undoManager = self.undoManager; } #pragma mark - UI Button Interaction - (IBAction)addFeed:(id)sender { [self showModalForFeedGroup:nil isGroupEdit:NO]; } - (IBAction)addGroup:(id)sender { [self showModalForFeedGroup:nil isGroupEdit:YES]; } - (IBAction)addSeparator:(id)sender { [self.undoManager beginUndoGrouping]; [self insertFeedGroupAtSelection:SEPARATOR].name = @"---"; [self.undoManager endUndoGrouping]; [self saveChanges]; } /// Remove user selected item from persistent store. - (IBAction)remove:(id)sender { [self.undoManager beginUndoGrouping]; NSArray *parentNodes = [self.dataStore.selectedNodes valueForKeyPath:@"parentNode"]; [self.dataStore remove:sender]; for (NSTreeNode *parent in parentNodes) { [self restoreOrderingAndIndexPathStr:parent]; } [self.undoManager endUndoGrouping]; [self saveChanges]; [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil]; } /// Open user selected item for editing. - (IBAction)doubleClickOutlineView:(NSOutlineView*)sender { if (sender.clickedRow == -1) return; // ignore clicks on column headers and where no row was selected FeedGroup *fg = [(NSTreeNode*)[sender itemAtRow:sender.clickedRow] representedObject]; [self showModalForFeedGroup:fg isGroupEdit:YES]; // yes will be overwritten anyway } - (IBAction)shareMenu:(NSButton*)sender { if (!sender.menu) { sender.menu = [[NSMenu alloc] initWithTitle:NSLocalizedString(@"Import / Export menu", nil)]; sender.menu.autoenablesItems = NO; [sender.menu addItemWithTitle:NSLocalizedString(@"Import Feeds …", nil) action:nil keyEquivalent:@""].tag = 101; [sender.menu addItemWithTitle:NSLocalizedString(@"Export Feeds …", nil) action:nil keyEquivalent:@""].tag = 102; // TODO: Add menus for online sync? email export? etc. } if ([sender.menu popUpMenuPositioningItem:nil atLocation:NSMakePoint(0,sender.frame.size.height) inView:sender]) { NSInteger tag = sender.menu.highlightedItem.tag; if (tag == 101) { [OpmlExport showImportDialog:self.view.window withTreeController:self.dataStore]; } else if (tag == 102) { [OpmlExport showExportDialog:self.view.window withContext:self.dataStore.managedObjectContext]; } } } #pragma mark - Insert & Edit Feed Items / Modal Dialog /// Save core data changes of current object context to persistent store - (void)saveChanges { [StoreCoordinator saveContext:self.dataStore.managedObjectContext andParent:YES]; } /** Open a new modal window to edit the selected @c FeedGroup. @note isGroupEdit @c flag will be overwritten if @c FeedGroup parameter is not @c nil. @param fg @c FeedGroup to be edited. If @c nil a new object will be created at the current selection. @param flag If @c YES open group edit modal dialog. If @c NO open feed edit modal dialog. */ - (void)showModalForFeedGroup:(FeedGroup*)fg isGroupEdit:(BOOL)flag { if (fg.type == SEPARATOR) return; [self.undoManager beginUndoGrouping]; if (!fg || ![fg isKindOfClass:[FeedGroup class]]) { fg = [self insertFeedGroupAtSelection:(flag ? GROUP : FEED)]; } ModalEditDialog *editDialog = (fg.type == GROUP ? [ModalGroupEdit modalWith:fg] : [ModalFeedEdit modalWith:fg]); [self.view.window beginSheet:[editDialog getModalSheet] completionHandler:^(NSModalResponse returnCode) { if (returnCode == NSModalResponseOK) { [editDialog applyChangesToCoreDataObject]; [self.undoManager endUndoGrouping]; } else { [self.undoManager endUndoGrouping]; [self.dataStore.managedObjectContext rollback]; } BOOL hasChanges = [self.dataStore.managedObjectContext hasChanges]; if (hasChanges) { [self saveChanges]; [self.dataStore rearrangeObjects]; } else { [self.undoManager disableUndoRegistration]; [self.undoManager undoNestedGroup]; [self.undoManager enableUndoRegistration]; } }]; } /// Insert @c FeedGroup item either after current selection or inside selected folder (if expanded) - (FeedGroup*)insertFeedGroupAtSelection:(FeedGroupType)type { FeedGroup *fg = [FeedGroup newGroup:type inContext:self.dataStore.managedObjectContext]; NSIndexPath *pth = [self indexPathForInsertAtNode:[[self.dataStore selectedNodes] firstObject]]; [self.dataStore insertObject:fg atArrangedObjectIndexPath:pth]; if (pth.length > 1) { // some subfolder and not root folder (has parent!) NSTreeNode *parentNode = [[self.dataStore arrangedObjects] descendantNodeAtIndexPath:pth].parentNode; fg.parent = parentNode.representedObject; [self restoreOrderingAndIndexPathStr:parentNode]; } else { [self restoreOrderingAndIndexPathStr:[self.dataStore arrangedObjects]]; // .parent = nil } return fg; } /** Index path will be selected as follow: - @b root: append at end - @b folder (expanded): append at front - @b else: append after item. @return indexPath where item will be inserted. */ - (NSIndexPath*)indexPathForInsertAtNode:(NSTreeNode*)node { if (!node) { // append to root return [NSIndexPath indexPathWithIndex:[self.dataStore arrangedObjects].childNodes.count]; // or 0 to append at front } else if ([self.outlineView isItemExpanded:node]) { // append to group (if open) return [node.indexPath indexPathByAddingIndex:0]; // or 'selection.childNodes.count' to append at end } else { // append before / after selected item NSIndexPath *pth = node.indexPath; // remove the two lines below to insert infront of selection (instead of after selection) NSUInteger lastIdx = [pth indexAtPosition:pth.length - 1]; return [[pth indexPathByRemovingLastIndex] indexPathByAddingIndex:lastIdx + 1]; } } /// 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.undoManager beginUndoGrouping]; [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.undoManager endUndoGrouping]; if (self.dataStore.managedObjectContext.hasChanges) { [self saveChanges]; } else { [self.undoManager disableUndoRegistration]; [self.undoManager undoNestedGroup]; [self.undoManager enableUndoRegistration]; } 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; NSIndexPath *dest = [destParent indexPath]; NSArray *previousParents = [self.currentlyDraggedNodes valueForKeyPath:@"parentNode"]; [self.dataStore moveNodes:self.currentlyDraggedNodes toIndexPath:[dest indexPathByAddingIndex:idx]]; for (NSTreeNode *node in previousParents) { [self restoreOrderingAndIndexPathStr:node]; } [self restoreOrderingAndIndexPathStr:destParent]; 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 /// Populate @c NSOutlineView data cells with core data object values. - (NSView *)outlineView:(NSOutlineView *)outlineView viewForTableColumn:(NSTableColumn *)tableColumn item:(id)item { FeedGroup *fg = [(NSTreeNode*)item representedObject]; BOOL isSeperator = (fg.type == SEPARATOR); BOOL isRefreshColumn = [tableColumn.identifier isEqualToString:@"RefreshColumn"]; NSString *cellIdent = (isRefreshColumn ? @"cellRefresh" : (isSeperator ? @"cellSeparator" : @"cellFeed")); // owner is nil to prohibit repeated awakeFromNib calls NSTableCellView *cellView = [self.outlineView makeViewWithIdentifier:cellIdent owner:nil]; if (isRefreshColumn) { cellView.textField.objectValue = fg.refreshStr; cellView.textField.textColor = (fg.refreshStr.length > 1 ? [NSColor controlTextColor] : [NSColor disabledControlTextColor]); } else if (isSeperator) { return cellView; // refresh cell already skipped with the above if condition } else { cellView.textField.objectValue = fg.name; cellView.imageView.image = (fg.type == GROUP ? [NSImage imageNamed:NSImageNameFolder] : [fg.feed iconImage16]); } return cellView; } #pragma mark - Keyboard Commands: undo, redo, copy, enter /// 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; if (aSelector == @selector(redo:)) return [self.undoManager canRedo] && self.undoManager.groupingLevel == 0; if (aSelector == @selector(copy:) || aSelector == @selector(enterPressed:)) { BOOL outlineHasFocus = [[self.view.window firstResponder] isKindOfClass:[NSOutlineView class]]; BOOL hasSelection = (self.dataStore.selectedNodes.count > 0); if (!outlineHasFocus || !hasSelection) return NO; if (aSelector == @selector(copy:)) return YES; // can edit only if selection is not a separator return (((FeedGroup*)self.dataStore.selectedNodes.firstObject.representedObject).type != SEPARATOR); } return [super respondsToSelector:aSelector]; } /// Perform undo operation and redraw UI & menu bar unread count - (void)undo:(id)sender { [self.undoManager undo]; [self saveChanges]; [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil]; [self.dataStore rearrangeObjects]; // update ordering } /// Perform redo operation and redraw UI & menu bar unread count - (void)redo:(id)sender { [self.undoManager redo]; [self saveChanges]; [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil]; [self.dataStore rearrangeObjects]; // update ordering } /// User pressed enter; open edit dialog for selected item. - (void)enterPressed:(id)sender { [self showModalForFeedGroup:self.dataStore.selectedObjects.firstObject isGroupEdit:YES]; // yes will be overwritten anyway } /// Copy human readable description of selected nodes to clipboard. - (void)copy:(id)sender { NSMutableString *str = [[NSMutableString alloc] init]; NSUInteger count = self.dataStore.selectedNodes.count; NSMutableArray *groups = [NSMutableArray arrayWithCapacity:count]; // filter out nodes that are already present in some selected parent node for (NSTreeNode *node in self.dataStore.selectedNodes) { BOOL skipItem = NO; for (NSTreeNode *stored in groups) { NSIndexPath *p = node.indexPath; while (p.length > stored.indexPath.length) p = [p indexPathByRemovingLastIndex]; if ([p isEqualTo:stored.indexPath]) { skipItem = YES; break; } } if (!skipItem) { [self traverseChildren:node appendString:str prefix:@""]; if (node.childNodes.count > 0) [groups addObject:node]; } } [[NSPasteboard generalPasteboard] clearContents]; [[NSPasteboard generalPasteboard] setString:str forType:NSPasteboardTypeString]; NSLog(@"%@", str); } /** 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 { [str appendFormat:@"%@%@\n", prefix, [obj.representedObject readableDescription]]; prefix = [prefix stringByAppendingString:@" "]; for (NSTreeNode *child in obj.childNodes) { [self traverseChildren:child appendString:str prefix:prefix]; } } @end