Files
baRSS/baRSS/Preferences/Feeds Tab/SettingsFeeds.m
2019-01-27 04:01:07 +01:00

506 lines
20 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"
#import "FeedDownload.h"
@interface SettingsFeeds ()
@property (weak) IBOutlet NSOutlineView *outlineView;
@property (weak) IBOutlet NSTreeController *dataStore;
@property (weak) IBOutlet NSProgressIndicator *spinner;
@property (weak) IBOutlet NSTextField *spinnerLabel;
@property (strong) NSArray<NSTreeNode*> *currentlyDraggedNodes;
@property (strong) NSUndoManager *undoManager;
@property (strong) NSTimer *timerStatusInfo;
@property (strong) NSDateComponentsFormatter *intervalFormatter;
@end
@implementation SettingsFeeds
// 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)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;
// Register for notifications
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateIcon:) name:kNotificationFeedUpdated object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateIcon:) name:kNotificationFeedIconUpdated object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateInProgress:) name:kNotificationBackgroundUpdateInProgress object:nil];
}
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
#pragma mark - Activity Spinner & Status Info
/// Initialize status info timer
- (void)viewWillAppear {
self.intervalFormatter = [[NSDateComponentsFormatter alloc] init];
self.intervalFormatter.unitsStyle = NSDateComponentsFormatterUnitsStyleShort; // e.g., '30 min'
self.intervalFormatter.maximumUnitCount = 1;
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;
self.intervalFormatter = nil;
}
/// Callback method to update status info. Will be called more often when interval is getting shorter.
- (void)keepTimerRunning {
NSDate *date = [FeedDownload dateScheduled];
if (date) {
double nextFire = fabs(date.timeIntervalSinceNow);
if (nextFire > 60) { // update 1/min
nextFire = fmod(nextFire, 60); // next update will align with minute
} else {
nextFire = 1; // update 1/sec
}
NSString *str = [self.intervalFormatter stringFromTimeInterval: date.timeIntervalSinceNow];
self.spinnerLabel.stringValue = [NSString stringWithFormat:NSLocalizedString(@"Next update in %@", nil), str];
[self.timerStatusInfo setFireDate:[NSDate dateWithTimeIntervalSinceNow: nextFire]];
}
}
/// Start ( @c c @c > @c 0 ) or stop ( @c c @c = @c 0 ) activity spinner. Also, sets status info.
- (void)activateSpinner:(NSInteger)c {
if (c == 0) {
[self.spinner stopAnimation:nil];
self.spinnerLabel.stringValue = @"";
[self.timerStatusInfo fire];
} else {
[self.timerStatusInfo setFireDate:[NSDate distantFuture]];
[self.spinner startAnimation:nil];
if (c == 1) { // exactly one feed
self.spinnerLabel.stringValue = NSLocalizedString(@"Updating 1 feed …", nil);
} else if (c < 0) { // unknown number of feeds
self.spinnerLabel.stringValue = NSLocalizedString(@"Updating feeds …", nil);
} else {
self.spinnerLabel.stringValue = [NSString stringWithFormat:NSLocalizedString(@"Updating %lu feeds …", nil), c];
}
}
}
#pragma mark - Notification callback methods
/// Callback method fired when feeds have been updated in the background.
- (void)updateIcon:(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
/// Add feed button.
- (IBAction)addFeed:(id)sender {
[self showModalForFeedGroup:nil isGroupEdit:NO];
}
/// Add group button.
- (IBAction)addGroup:(id)sender {
[self showModalForFeedGroup:nil isGroupEdit:YES];
}
/// Add separator button.
- (IBAction)addSeparator:(id)sender {
[self beginCoreDataChange];
[self insertFeedGroupAtSelection:SEPARATOR].name = @"---";
[self endCoreDataChangeShouldUndo:NO];
}
/// Remove feed button. User has selected one or more item in outline view.
- (IBAction)remove:(id)sender {
[self beginCoreDataChange];
NSArray<NSTreeNode*> *parentNodes = [self.dataStore.selectedNodes valueForKeyPath:@"parentNode"];
[self.dataStore remove:sender];
for (NSTreeNode *parent in parentNodes) {
[self restoreOrderingAndIndexPathStr:parent];
}
[self endCoreDataChangeShouldUndo:NO];
[[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
}
/// Share menu button. Currently only import & export feeds as OPML.
- (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 withContext:self.dataStore.managedObjectContext];
} else if (tag == 102) {
[OpmlExport showExportDialog:self.view.window withContext:self.dataStore.managedObjectContext];
}
}
}
#pragma mark - Insert & Edit Feed Items / Modal Dialog
/**
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 beginCoreDataChange];
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];
}
if ([self endCoreDataChangeShouldUndo:(returnCode != NSModalResponseOK)]) {
[self.dataStore rearrangeObjects];
}
}];
}
/// 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 beginCoreDataChange];
[pboard declareTypes:[NSArray arrayWithObject:dragNodeType] owner:self];
[pboard setString:@"dragging" forType:dragNodeType];
self.currentlyDraggedNodes = items;
return YES;
}
/// Finish drag-n-drop operation by saving changes to persistent store
- (void)outlineView:(NSOutlineView *)outlineView draggingSession:(NSDraggingSession *)session endedAtPoint:(NSPoint)screenPoint operation:(NSDragOperation)operation {
[self endCoreDataChangeShouldUndo:NO];
self.currentlyDraggedNodes = nil;
}
/// Perform drag-n-drop operation, move nodes to new destination and update all indices
- (BOOL)outlineView:(NSOutlineView *)outlineView acceptDrop:(id <NSDraggingInfo>)info item:(id)item childIndex:(NSInteger)index {
NSTreeNode *destParent = (item != nil ? item : [self.dataStore arrangedObjects]);
NSUInteger idx = (NSUInteger)index;
if (index == -1) // drag items on folder or root drop
idx = destParent.childNodes.count;
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) {
NSString *str = [fg refreshString];
cellView.textField.stringValue = str;
cellView.textField.textColor = (str.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 && ![FeedDownload isUpdating];
if (aSelector == @selector(redo:))
return [self.undoManager canRedo] && self.undoManager.groupingLevel == 0 && ![FeedDownload isUpdating];
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 saveWithUnpredictableChange];
}
/// Perform redo operation and redraw UI & menu bar unread count
- (void)redo:(id)sender {
[self.undoManager redo];
[self saveWithUnpredictableChange];
}
/// 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];
}
/**
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