Files
baRSS/baRSS/Preferences/Feeds Tab/SettingsFeeds.m

383 lines
15 KiB
Objective-C

//
// 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<NSTreeNode*> *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<NSTreeNode*> *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<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.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 <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;
NSIndexPath *dest = [destParent indexPath];
NSArray<NSTreeNode*> *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 <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
/// 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<NSTreeNode*> *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