Refactoring Part 3: Feed configuration and CoreData Model
This commit is contained in:
@@ -21,22 +21,20 @@
|
||||
// SOFTWARE.
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import "ModalSheet.h"
|
||||
|
||||
@class FeedConfig;
|
||||
@class FeedGroup;
|
||||
|
||||
@protocol ModalEditDelegate <NSObject>
|
||||
- (void)modalDidUpdateFeedConfig:(FeedConfig*)config;
|
||||
@end
|
||||
|
||||
@protocol ModalFeedConfigEdit <NSObject>
|
||||
@property (weak) id<ModalEditDelegate> delegate;
|
||||
- (void)updateRepresentedObject; // must call [item.managedObjectContext refreshObject:item mergeChanges:YES];
|
||||
@interface ModalEditDialog : NSViewController
|
||||
+ (instancetype)modalWith:(FeedGroup*)group;
|
||||
- (ModalSheet*)getModalSheet;
|
||||
- (void)applyChangesToCoreDataObject;
|
||||
@end
|
||||
|
||||
|
||||
@interface ModalFeedEdit : NSViewController <ModalFeedConfigEdit, NSTextFieldDelegate>
|
||||
@interface ModalFeedEdit : ModalEditDialog <NSTextFieldDelegate>
|
||||
@end
|
||||
|
||||
@interface ModalGroupEdit : NSViewController <ModalFeedConfigEdit>
|
||||
@interface ModalGroupEdit : ModalEditDialog
|
||||
@end
|
||||
|
||||
|
||||
@@ -23,6 +23,42 @@
|
||||
#import "ModalFeedEdit.h"
|
||||
#import "FeedDownload.h"
|
||||
#import "StoreCoordinator.h"
|
||||
#import "Feed+Ext.h"
|
||||
#import "FeedMeta+Ext.h"
|
||||
#import "FeedGroup+Ext.h"
|
||||
|
||||
|
||||
#pragma mark - ModalEditDialog -
|
||||
|
||||
|
||||
@interface ModalEditDialog()
|
||||
@property (strong) FeedGroup *feedGroup;
|
||||
@property (strong) ModalSheet *modalSheet;
|
||||
@end
|
||||
|
||||
@implementation ModalEditDialog
|
||||
/// Dedicated initializer for @c ModalEditDialog subclasses. Ensures @c .feedGroup property is set.
|
||||
+ (instancetype)modalWith:(FeedGroup*)group {
|
||||
ModalEditDialog *diag = [self new];
|
||||
diag.feedGroup = group;
|
||||
return diag;
|
||||
}
|
||||
/// @return New @c ModalSheet with its subclass @c .view property as dialog content.
|
||||
- (ModalSheet *)getModalSheet {
|
||||
if (!self.modalSheet)
|
||||
self.modalSheet = [ModalSheet modalWithView:self.view];
|
||||
return self.modalSheet;
|
||||
}
|
||||
/// This method should be overridden by subclasses. Used to save changes to persistent store.
|
||||
- (void)applyChangesToCoreDataObject {
|
||||
NSLog(@"[%@] is missing method: -(void)applyChangesToCoreDataObject", [self class]);
|
||||
NSAssert(NO, @"Override required!");
|
||||
}
|
||||
@end
|
||||
|
||||
|
||||
#pragma mark - ModalFeedEdit -
|
||||
|
||||
|
||||
@interface ModalFeedEdit()
|
||||
@property (weak) IBOutlet NSTextField *url;
|
||||
@@ -34,143 +70,135 @@
|
||||
@property (weak) IBOutlet NSButton *warningIndicator;
|
||||
@property (weak) IBOutlet NSPopover *warningPopover;
|
||||
|
||||
@property (copy) NSString *previousURL;
|
||||
@property (copy) NSString *previousURL; // check if changed and avoid multiple download
|
||||
@property (copy) NSString *httpDate;
|
||||
@property (copy) NSString *httpEtag;
|
||||
@property (strong) NSError *feedError;
|
||||
@property (strong) RSParsedFeed *feedResult;
|
||||
|
||||
@property (assign) BOOL shouldSaveObject;
|
||||
@property (assign) BOOL shouldDeletePrevArticles;
|
||||
@property (assign) BOOL objectNeedsSaving;
|
||||
@property (assign) BOOL objectIsModified;
|
||||
@property (strong) NSError *feedError; // download error or xml parser error
|
||||
@property (strong) RSParsedFeed *feedResult; // parsed result
|
||||
@property (assign) BOOL didDownloadFeed; // check if feed articles need update
|
||||
@end
|
||||
|
||||
@implementation ModalFeedEdit
|
||||
@synthesize delegate;
|
||||
|
||||
/// Init feed edit dialog with default values.
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
self.previousURL = @"";
|
||||
self.refreshNum.intValue = 30;
|
||||
self.shouldSaveObject = NO;
|
||||
self.shouldDeletePrevArticles = NO;
|
||||
self.objectNeedsSaving = NO;
|
||||
self.objectIsModified = NO;
|
||||
[self populateTextFields:self.feedGroup];
|
||||
}
|
||||
|
||||
/**
|
||||
Pre-fill UI control field values with @c FeedGroup properties.
|
||||
*/
|
||||
- (void)populateTextFields:(FeedGroup*)fg {
|
||||
if (!fg || [fg hasChanges]) return; // hasChanges is true only if newly created
|
||||
self.name.objectValue = fg.name;
|
||||
self.url.objectValue = fg.feed.meta.url;
|
||||
self.previousURL = self.url.stringValue;
|
||||
self.refreshNum.intValue = fg.feed.meta.refreshNum;
|
||||
NSInteger unit = (NSInteger)fg.feed.meta.refreshUnit;
|
||||
if (unit < 0 || unit > self.refreshUnit.numberOfItems - 1)
|
||||
unit = self.refreshUnit.numberOfItems - 1;
|
||||
[self.refreshUnit selectItemAtIndex:unit];
|
||||
}
|
||||
|
||||
#pragma mark - Edit Feed Data
|
||||
|
||||
/**
|
||||
Use UI control field values to update the represented core data object. Also parse new articles if applicable.
|
||||
Set @c scheduled to a new date if refresh interval was changed.
|
||||
*/
|
||||
- (void)applyChangesToCoreDataObject {
|
||||
FeedMeta *meta = self.feedGroup.feed.meta;
|
||||
BOOL intervalChanged = [meta setURL:self.previousURL refresh:self.refreshNum.intValue unit:(int16_t)self.refreshUnit.indexOfSelectedItem];
|
||||
if (intervalChanged)
|
||||
[meta calculateAndSetScheduled]; // updateTimer will be scheduled once preferences is closed
|
||||
[self.feedGroup setName:self.name.stringValue andRefreshString:[meta readableRefreshString]];
|
||||
if (self.didDownloadFeed) {
|
||||
[meta setEtag:self.httpEtag modified:self.httpDate];
|
||||
[self.feedGroup.feed updateWithRSS:self.feedResult postUnreadCountChange:YES];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Prepare UI (nullify @c result, @c error and start @c ProgressIndicator) and perform HTTP request.
|
||||
Articles will be parsed and stored in class variables.
|
||||
This should avoid unnecessary core data operations if user decides to cancel the edit.
|
||||
The save operation will only be executed if user clicks on the 'OK' button.
|
||||
*/
|
||||
- (void)downloadRSS {
|
||||
[self.modalSheet setDoneEnabled:NO];
|
||||
// Assuming the user has not changed title since the last fetch.
|
||||
// Reset to "" because after download it will be pre-filled with new feed title
|
||||
if ([self.name.stringValue isEqualToString:self.feedResult.title]) {
|
||||
self.name.stringValue = @"";
|
||||
}
|
||||
self.feedResult = nil;
|
||||
self.feedError = nil;
|
||||
self.httpEtag = nil;
|
||||
self.httpDate = nil;
|
||||
self.didDownloadFeed = NO;
|
||||
[self.spinnerURL startAnimation:nil];
|
||||
[self.spinnerName startAnimation:nil];
|
||||
|
||||
FeedConfig *fc = [self feedConfigOrNil];
|
||||
if (fc) {
|
||||
self.url.objectValue = fc.url;
|
||||
self.name.objectValue = fc.name;
|
||||
self.refreshNum.intValue = fc.refreshNum;
|
||||
NSInteger unitIndex = fc.refreshUnit;
|
||||
if (unitIndex < 0 || unitIndex > self.refreshUnit.numberOfItems - 1)
|
||||
unitIndex = self.refreshUnit.numberOfItems - 1;
|
||||
[self.refreshUnit selectItemAtIndex:unitIndex];
|
||||
|
||||
self.previousURL = self.url.stringValue;
|
||||
[FeedDownload newFeed:self.previousURL block:^(RSParsedFeed *result, NSError *error, NSHTTPURLResponse* response) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (self.modalSheet.closeInitiated)
|
||||
return;
|
||||
self.didDownloadFeed = YES;
|
||||
self.feedResult = result;
|
||||
self.feedError = error; // MAIN THREAD!: warning indicator .hidden is bound to feedError
|
||||
self.httpEtag = [response allHeaderFields][@"Etag"];
|
||||
self.httpDate = [response allHeaderFields][@"Date"]; // @"Expires", @"Last-Modified"
|
||||
[self updateTextFieldURL:response.URL.absoluteString andTitle:result.title];
|
||||
// TODO: add icon download
|
||||
// TODO: play error sound?
|
||||
[self.spinnerURL stopAnimation:nil];
|
||||
[self.spinnerName stopAnimation:nil];
|
||||
[self.modalSheet setDoneEnabled:YES];
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
/// Set UI TextField values to downloaded values. Title will be updated if TextField is empty. URL on redirect.
|
||||
- (void)updateTextFieldURL:(NSString*)responseURL andTitle:(NSString*)feedTitle {
|
||||
// If URL was redirected (e.g., https redirect), replace original text field value with new one
|
||||
if (responseURL.length > 0 && ![responseURL isEqualToString:self.previousURL]) {
|
||||
self.previousURL = responseURL;
|
||||
self.url.stringValue = responseURL;
|
||||
}
|
||||
// Copy feed title to text field. (only if user hasn't set anything else yet)
|
||||
if ([self.name.stringValue isEqualToString:@""] && feedTitle.length > 0) {
|
||||
self.name.stringValue = feedTitle; // no damage to replace an empty string
|
||||
}
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
if (self.shouldSaveObject) {
|
||||
if (self.objectNeedsSaving)
|
||||
[self updateRepresentedObject];
|
||||
FeedConfig *item = [self feedConfigOrNil];
|
||||
NSUndoManager *um = item.managedObjectContext.undoManager;
|
||||
[um endUndoGrouping];
|
||||
if (!self.objectIsModified) {
|
||||
[um disableUndoRegistration];
|
||||
[um undoNestedGroup];
|
||||
[um enableUndoRegistration];
|
||||
} else {
|
||||
[self.delegate modalDidUpdateFeedConfig:item];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)updateRepresentedObject {
|
||||
FeedConfig *item = [self feedConfigOrNil];
|
||||
if (!item)
|
||||
return;
|
||||
if (!self.shouldSaveObject) // first call to this method
|
||||
[item.managedObjectContext.undoManager beginUndoGrouping];
|
||||
self.shouldSaveObject = YES;
|
||||
self.objectNeedsSaving = NO; // after this method it is saved
|
||||
|
||||
// if's to prevent unnecessary undo groups if nothing has changed
|
||||
if (![item.name isEqualToString: self.name.stringValue])
|
||||
item.name = self.name.stringValue;
|
||||
if (![item.url isEqualToString:self.url.stringValue])
|
||||
item.url = self.url.stringValue;
|
||||
if (item.refreshNum != self.refreshNum.intValue)
|
||||
item.refreshNum = self.refreshNum.intValue;
|
||||
if (item.refreshUnit != self.refreshUnit.indexOfSelectedItem)
|
||||
item.refreshUnit = (int16_t)self.refreshUnit.indexOfSelectedItem;
|
||||
|
||||
if (self.shouldDeletePrevArticles) {
|
||||
[item updateRSSFeed:self.feedResult];
|
||||
[item setEtag:self.httpEtag modified:self.httpDate];
|
||||
// TODO: add icon download
|
||||
}
|
||||
if ([item.managedObjectContext hasChanges]) {
|
||||
self.objectIsModified = YES;
|
||||
[item calculateAndSetScheduled];
|
||||
[item.managedObjectContext refreshObject:item mergeChanges:YES];
|
||||
}
|
||||
}
|
||||
|
||||
- (FeedConfig*)feedConfigOrNil {
|
||||
if ([self.representedObject isKindOfClass:[FeedConfig class]])
|
||||
return self.representedObject;
|
||||
return nil;
|
||||
}
|
||||
#pragma mark - NSTextField Delegate
|
||||
|
||||
/// Helper method to check whether url was modified since last download.
|
||||
- (BOOL)urlHasChanged {
|
||||
return ![self.previousURL isEqualToString:self.url.stringValue];
|
||||
}
|
||||
|
||||
/// Hide warning button if an error was present but the user changed the url since.
|
||||
- (void)controlTextDidChange:(NSNotification *)obj {
|
||||
if (obj.object == self.url) {
|
||||
self.warningIndicator.hidden = (!self.feedError || [self urlHasChanged]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whenever the user finished entering the url (return key or focus change) perform a download request.
|
||||
- (void)controlTextDidEndEditing:(NSNotification *)obj {
|
||||
if (obj.object == self.url && [self urlHasChanged]) {
|
||||
self.shouldDeletePrevArticles = YES;
|
||||
if (self.modalSheet.closeInitiated)
|
||||
return;
|
||||
self.previousURL = self.url.stringValue;
|
||||
self.feedResult = nil;
|
||||
self.feedError = nil;
|
||||
[self.spinnerURL startAnimation:nil];
|
||||
[self.spinnerName startAnimation:nil];
|
||||
[FeedDownload newFeed:self.previousURL block:^(RSParsedFeed *result, NSError *error, NSHTTPURLResponse* response) {
|
||||
self.feedResult = result;
|
||||
self.httpDate = [response allHeaderFields][@"Date"]; // @"Expires", @"Last-Modified"
|
||||
self.httpEtag = [response allHeaderFields][@"Etag"];
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (response && ![response.URL.absoluteString isEqualToString:self.url.stringValue]) {
|
||||
// URL was redirected, so replace original text field value with new one
|
||||
self.url.stringValue = response.URL.absoluteString;
|
||||
self.previousURL = self.url.stringValue;
|
||||
}
|
||||
// TODO: play error sound?
|
||||
self.feedError = error; // warning indicator .hidden is bound to feedError
|
||||
self.objectNeedsSaving = YES; // stays YES if this block runs after updateRepresentedObject:
|
||||
[self setTitleFromFeed];
|
||||
[self.spinnerURL stopAnimation:nil];
|
||||
[self.spinnerName stopAnimation:nil];
|
||||
});
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)setTitleFromFeed {
|
||||
if ([self.name.stringValue isEqualToString:@""]) {
|
||||
self.name.objectValue = self.feedResult.title;
|
||||
[self downloadRSS];
|
||||
}
|
||||
}
|
||||
|
||||
/// Warning button next to url text field. Will be visible if an error occurs during download.
|
||||
- (IBAction)didClickWarningButton:(NSButton*)sender {
|
||||
if (!self.feedError)
|
||||
return;
|
||||
@@ -191,51 +219,49 @@
|
||||
@end
|
||||
|
||||
|
||||
#pragma mark - ModalGroupEdit
|
||||
#pragma mark - ModalGroupEdit -
|
||||
|
||||
|
||||
@implementation ModalGroupEdit
|
||||
@synthesize delegate;
|
||||
/// Init view and set group name if edeting an already existing object.
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
if ([self.representedObject isKindOfClass:[FeedConfig class]]) {
|
||||
FeedConfig *fc = self.representedObject;
|
||||
((NSTextField*)self.view).objectValue = fc.name;
|
||||
}
|
||||
if (self.feedGroup && ![self.feedGroup hasChanges]) // hasChanges is true only if newly created
|
||||
((NSTextField*)self.view).objectValue = self.feedGroup.name;
|
||||
}
|
||||
/// Set one single @c NSTextField as entire view. Populate with default value and placeholder.
|
||||
- (void)loadView {
|
||||
NSTextField *tf = [NSTextField textFieldWithString:NSLocalizedString(@"New Group", nil)];
|
||||
tf.placeholderString = NSLocalizedString(@"New Group", nil);
|
||||
tf.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin;
|
||||
self.view = tf;
|
||||
}
|
||||
- (void)updateRepresentedObject {
|
||||
if ([self.representedObject isKindOfClass:[FeedConfig class]]) {
|
||||
FeedConfig *item = self.representedObject;
|
||||
NSString *name = ((NSTextField*)self.view).stringValue;
|
||||
if (![item.name isEqualToString: name]) {
|
||||
item.name = name;
|
||||
[item.managedObjectContext refreshObject:item mergeChanges:YES];
|
||||
[self.delegate modalDidUpdateFeedConfig:item];
|
||||
}
|
||||
}
|
||||
/// Edit of group finished. Save changes to core data object and perform save operation on delegate.
|
||||
- (void)applyChangesToCoreDataObject {
|
||||
NSString *name = ((NSTextField*)self.view).stringValue;
|
||||
if (![self.feedGroup.name isEqualToString:name])
|
||||
self.feedGroup.name = name;
|
||||
}
|
||||
@end
|
||||
|
||||
|
||||
#pragma mark - StrictUIntFormatter
|
||||
#pragma mark - StrictUIntFormatter -
|
||||
|
||||
|
||||
@interface StrictUIntFormatter : NSFormatter
|
||||
@end
|
||||
|
||||
@implementation StrictUIntFormatter
|
||||
/// Display object as integer formatted string.
|
||||
- (NSString *)stringForObjectValue:(id)obj {
|
||||
return [NSString stringWithFormat:@"%d", [[NSString stringWithFormat:@"%@", obj] intValue]];
|
||||
}
|
||||
/// Parse any pasted input as integer.
|
||||
- (BOOL)getObjectValue:(out id _Nullable __autoreleasing *)obj forString:(NSString *)string errorDescription:(out NSString *__autoreleasing _Nullable *)error {
|
||||
*obj = [[NSNumber numberWithInt:[string intValue]] stringValue];
|
||||
return YES;
|
||||
}
|
||||
/// Only digits, no other character allowed
|
||||
- (BOOL)isPartialStringValid:(NSString *__autoreleasing _Nonnull *)partialStringPtr proposedSelectedRange:(NSRangePointer)proposedSelRangePtr originalString:(NSString *)origString originalSelectedRange:(NSRange)origSelRange errorDescription:(NSString *__autoreleasing _Nullable *)error {
|
||||
for (NSUInteger i = 0; i < [*partialStringPtr length]; i++) {
|
||||
unichar c = [*partialStringPtr characterAtIndex:i];
|
||||
|
||||
@@ -21,18 +21,17 @@
|
||||
// SOFTWARE.
|
||||
|
||||
#import "SettingsFeeds.h"
|
||||
#import "BarMenu.h"
|
||||
#import "ModalSheet.h"
|
||||
#import "ModalFeedEdit.h"
|
||||
#import "Constants.h"
|
||||
#import "DrawImage.h"
|
||||
#import "StoreCoordinator.h"
|
||||
#import "Constants.h"
|
||||
#import "ModalFeedEdit.h"
|
||||
#import "Feed+Ext.h"
|
||||
#import "FeedGroup+Ext.h"
|
||||
|
||||
@interface SettingsFeeds () <ModalEditDelegate>
|
||||
@interface SettingsFeeds ()
|
||||
@property (weak) IBOutlet NSOutlineView *outlineView;
|
||||
@property (weak) IBOutlet NSTreeController *dataStore;
|
||||
|
||||
@property (strong) NSViewController<ModalFeedConfigEdit> *modalController;
|
||||
@property (strong) NSArray<NSTreeNode*> *currentlyDraggedNodes;
|
||||
@property (strong) NSUndoManager *undoManager;
|
||||
@end
|
||||
@@ -60,22 +59,21 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
||||
}
|
||||
|
||||
- (IBAction)addFeed:(id)sender {
|
||||
[self showModalForFeedConfig:nil isGroupEdit:NO];
|
||||
[self showModalForFeedGroup:nil isGroupEdit:NO];
|
||||
}
|
||||
|
||||
- (IBAction)addGroup:(id)sender {
|
||||
[self showModalForFeedConfig:nil isGroupEdit:YES];
|
||||
[self showModalForFeedGroup:nil isGroupEdit:YES];
|
||||
}
|
||||
|
||||
- (IBAction)addSeparator:(id)sender {
|
||||
[self.undoManager beginUndoGrouping];
|
||||
FeedConfig *sp = [self insertSortedItemAtSelection];
|
||||
sp.name = @"---";
|
||||
sp.typ = SEPARATOR;
|
||||
[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"];
|
||||
@@ -88,99 +86,104 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
||||
[[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
|
||||
|
||||
FeedConfig *fc = [(NSTreeNode*)[sender itemAtRow:sender.clickedRow] representedObject];
|
||||
[self showModalForFeedConfig:fc isGroupEdit:YES]; // yes will be overwritten anyway
|
||||
FeedGroup *fg = [(NSTreeNode*)[sender itemAtRow:sender.clickedRow] representedObject];
|
||||
[self showModalForFeedGroup:fg isGroupEdit:YES]; // yes will be overwritten anyway
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Insert & Edit Feed Items
|
||||
|
||||
|
||||
- (void)openModalForSelection {
|
||||
[self showModalForFeedConfig:self.dataStore.selectedObjects.firstObject isGroupEdit:YES]; // yes will be overwritten anyway
|
||||
}
|
||||
|
||||
- (void)showModalForFeedConfig:(FeedConfig*)obj isGroupEdit:(BOOL)group {
|
||||
BOOL existingItem = [obj isKindOfClass:[FeedConfig class]];
|
||||
if (existingItem) {
|
||||
if (obj.typ == SEPARATOR) return;
|
||||
group = (obj.typ == GROUP);
|
||||
/**
|
||||
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.typ == SEPARATOR) return;
|
||||
[self.undoManager beginUndoGrouping];
|
||||
if (!fg || ![fg isKindOfClass:[FeedGroup class]]) {
|
||||
fg = [self insertFeedGroupAtSelection:(flag ? GROUP : FEED)];
|
||||
}
|
||||
self.modalController = (group ? [ModalGroupEdit new] : [ModalFeedEdit new]);
|
||||
self.modalController.representedObject = obj;
|
||||
self.modalController.delegate = self;
|
||||
|
||||
[self.view.window beginSheet:[ModalSheet modalWithView:self.modalController.view] completionHandler:^(NSModalResponse returnCode) {
|
||||
ModalEditDialog *editDialog = (fg.typ == GROUP ? [ModalGroupEdit modalWith:fg] : [ModalFeedEdit modalWith:fg]);
|
||||
|
||||
[self.view.window beginSheet:[editDialog getModalSheet] completionHandler:^(NSModalResponse returnCode) {
|
||||
if (returnCode == NSModalResponseOK) {
|
||||
if (!existingItem) { // create new item
|
||||
[self.undoManager beginUndoGrouping];
|
||||
FeedConfig *item = [self insertSortedItemAtSelection];
|
||||
item.typ = (group ? GROUP : FEED);
|
||||
self.modalController.representedObject = item;
|
||||
}
|
||||
[self.modalController updateRepresentedObject];
|
||||
if (!existingItem)
|
||||
[self.undoManager endUndoGrouping];
|
||||
[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];
|
||||
}
|
||||
self.modalController = nil;
|
||||
}];
|
||||
}
|
||||
|
||||
/// Called after an item was modified. May be called twice if download was still in progress.
|
||||
- (void)modalDidUpdateFeedConfig:(FeedConfig*)config {
|
||||
[self saveChanges];
|
||||
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Helper -
|
||||
|
||||
/// Insert @c FeedConfig item either after current selection or inside selected folder (if expanded)
|
||||
- (FeedConfig*)insertSortedItemAtSelection {
|
||||
FeedConfig *newItem = [[FeedConfig alloc] initWithEntity:FeedConfig.entity insertIntoManagedObjectContext:self.dataStore.managedObjectContext];
|
||||
NSTreeNode *selection = [[self.dataStore selectedNodes] firstObject];
|
||||
NSIndexPath *pth = nil;
|
||||
/// 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 (!selection) { // append to root
|
||||
pth = [NSIndexPath indexPathWithIndex:[self.dataStore arrangedObjects].childNodes.count]; // or 0 to append at front
|
||||
} else if ([self.outlineView isItemExpanded:selection]) { // append to group (if open)
|
||||
pth = [selection.indexPath indexPathByAddingIndex:0]; // or 'selection.childNodes.count' to append at end
|
||||
} else { // append before / after selected item
|
||||
pth = selection.indexPath;
|
||||
// remove the two lines below to insert infront of selection (instead of after selection)
|
||||
NSUInteger lastIdx = [pth indexAtPosition:pth.length - 1];
|
||||
pth = [[pth indexPathByRemovingLastIndex] indexPathByAddingIndex:lastIdx + 1];
|
||||
}
|
||||
[self.dataStore insertObject:newItem atArrangedObjectIndexPath:pth];
|
||||
|
||||
if (pth.length > 2) { // some subfolder; not root folder (has parent!)
|
||||
if (pth.length > 1) { // some subfolder and not root folder (has parent!)
|
||||
NSTreeNode *parentNode = [[self.dataStore arrangedObjects] descendantNodeAtIndexPath:pth].parentNode;
|
||||
newItem.parent = parentNode.representedObject;
|
||||
fg.parent = parentNode.representedObject;
|
||||
[self restoreOrderingAndIndexPathStr:parentNode];
|
||||
} else {
|
||||
[self restoreOrderingAndIndexPathStr:[self.dataStore arrangedObjects]]; // .parent = nil
|
||||
}
|
||||
return newItem;
|
||||
return fg;
|
||||
}
|
||||
|
||||
/// Loop over all descendants and update @c sortIndex @c (FeedConfig) as well as all @c indexPath @c (Feed)
|
||||
/**
|
||||
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++) {
|
||||
NSTreeNode *n = [children objectAtIndex:i];
|
||||
FeedConfig *fc = n.representedObject;
|
||||
// Re-calculate sort index for all affected parents
|
||||
if (fc.sortIndex != (int32_t)i)
|
||||
fc.sortIndex = (int32_t)i;
|
||||
// Re-calculate index path for all contained feed items
|
||||
[fc iterateSorted:NO overDescendantFeeds:^(Feed *feed, BOOL *cancel) {
|
||||
NSString *pthStr = [feed.config indexPathString];
|
||||
if (![feed.indexPath isEqualToString:pthStr])
|
||||
feed.indexPath = pthStr;
|
||||
FeedGroup *fg = [children objectAtIndex:i].representedObject;
|
||||
if (fg.sortIndex != (int32_t)i)
|
||||
fg.sortIndex = (int32_t)i;
|
||||
NSLog(@"%@ - %d", fg.name, fg.sortIndex);
|
||||
[fg iterateSorted:NO overDescendantFeeds:^(Feed *feed, BOOL *cancel) {
|
||||
[feed calculateAndSetIndexPathString];
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -189,6 +192,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
||||
#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];
|
||||
@@ -197,6 +201,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
||||
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) {
|
||||
@@ -209,6 +214,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
||||
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;
|
||||
@@ -223,17 +229,16 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
||||
[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 {
|
||||
FeedConfig *fc = [(NSTreeNode*)item representedObject];
|
||||
if (index == -1 && fc.typ != GROUP) { // if drag is on specific item and that item isnt a group
|
||||
NSTreeNode *parent = item;
|
||||
if (index == -1 && [parent isLeaf]) { // if drag is on specific item and that item isnt a group
|
||||
return NSDragOperationNone;
|
||||
}
|
||||
|
||||
NSTreeNode *parent = item;
|
||||
while (parent != nil) {
|
||||
for (NSTreeNode *node in self.currentlyDraggedNodes) {
|
||||
if (parent == node)
|
||||
@@ -248,10 +253,11 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
||||
#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 {
|
||||
FeedConfig *f = [(NSTreeNode*)item representedObject];
|
||||
BOOL isFeed = (f.typ == FEED);
|
||||
BOOL isSeperator = (f.typ == SEPARATOR);
|
||||
FeedGroup *fg = [(NSTreeNode*)item representedObject];
|
||||
BOOL isFeed = (fg.typ == FEED);
|
||||
BOOL isSeperator = (fg.typ == SEPARATOR);
|
||||
BOOL isRefreshColumn = [tableColumn.identifier isEqualToString:@"RefreshColumn"];
|
||||
|
||||
NSString *cellIdent = (isRefreshColumn ? @"cellRefresh" : (isSeperator ? @"cellSeparator" : @"cellFeed"));
|
||||
@@ -259,12 +265,12 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
||||
NSTableCellView *cellView = [self.outlineView makeViewWithIdentifier:cellIdent owner:nil];
|
||||
|
||||
if (isRefreshColumn) {
|
||||
cellView.textField.stringValue = (!isFeed ? @"" : [f readableRefreshString]);
|
||||
cellView.textField.stringValue = (isFeed && fg.refreshStr.length > 0 ? fg.refreshStr : @"");
|
||||
} else if (isSeperator) {
|
||||
return cellView; // the refresh cell is already skipped with the above if condition
|
||||
} else {
|
||||
cellView.textField.objectValue = f.name;
|
||||
if (f.typ == GROUP) {
|
||||
cellView.textField.objectValue = fg.name;
|
||||
if (fg.typ == GROUP) {
|
||||
cellView.imageView.image = [NSImage imageNamed:NSImageNameFolder];
|
||||
} else {
|
||||
// TODO: load icon
|
||||
@@ -275,8 +281,10 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
||||
cellView.imageView.image = defaultRSSIcon;
|
||||
}
|
||||
}
|
||||
if (isFeed) // also for refresh column
|
||||
cellView.textField.textColor = (f.refreshNum == 0 ? [NSColor disabledControlTextColor] : [NSColor controlTextColor]);
|
||||
if (isFeed) {// also for refresh column
|
||||
BOOL feedDisbaled = (fg.refreshStr.length == 0 || [fg.refreshStr characterAtIndex:0] == '0');
|
||||
cellView.textField.textColor = (feedDisbaled ? [NSColor disabledControlTextColor] : [NSColor controlTextColor]);
|
||||
}
|
||||
return cellView;
|
||||
}
|
||||
|
||||
@@ -284,6 +292,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
||||
#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];
|
||||
if (aSelector == @selector(redo:)) return [self.undoManager canRedo];
|
||||
@@ -295,11 +304,12 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
||||
if (aSelector == @selector(copy:))
|
||||
return YES;
|
||||
// can edit only if selection is not a separator
|
||||
return (((FeedConfig*)self.dataStore.selectedNodes.firstObject.representedObject).typ != SEPARATOR);
|
||||
return (((FeedGroup*)self.dataStore.selectedNodes.firstObject.representedObject).typ != SEPARATOR);
|
||||
}
|
||||
return [super respondsToSelector:aSelector];
|
||||
}
|
||||
|
||||
/// Perform undo operation and redraw UI & menu bar unread count
|
||||
- (void)undo:(id)sender {
|
||||
[self.undoManager undo];
|
||||
[self saveChanges];
|
||||
@@ -307,6 +317,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
||||
[self.dataStore rearrangeObjects]; // update ordering
|
||||
}
|
||||
|
||||
/// Perform redo operation and redraw UI & menu bar unread count
|
||||
- (void)redo:(id)sender {
|
||||
[self.undoManager redo];
|
||||
[self saveChanges];
|
||||
@@ -314,10 +325,12 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
|
||||
[self.dataStore rearrangeObjects]; // update ordering
|
||||
}
|
||||
|
||||
/// User pressed enter; open edit dialog for selected item.
|
||||
- (void)enterPressed:(id)sender {
|
||||
[self openModalForSelection];
|
||||
[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;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</customObject>
|
||||
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
|
||||
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
|
||||
<treeController mode="entity" entityName="FeedConfig" fetchPredicateFormat="parent == nil" automaticallyPreparesContent="YES" childrenKeyPath="children" leafKeyPath="type" id="JPf-gH-wxm"/>
|
||||
<treeController mode="entity" entityName="FeedGroup" fetchPredicateFormat="parent == nil" automaticallyPreparesContent="YES" childrenKeyPath="children" leafKeyPath="type" id="JPf-gH-wxm"/>
|
||||
<customView id="zfc-Ie-Sdx" userLabel="View">
|
||||
<rect key="frame" x="0.0" y="0.0" width="320" height="327"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
#import "BarMenu.h"
|
||||
#import "UserPrefs.h"
|
||||
#import "StoreCoordinator.h"
|
||||
#import <ServiceManagement/ServiceManagement.h>
|
||||
|
||||
#import <ServiceManagement/ServiceManagement.h>
|
||||
|
||||
@interface SettingsGeneral()
|
||||
@property (weak) IBOutlet NSPopUpButton *popupHttpApplication;
|
||||
@@ -48,6 +48,7 @@
|
||||
|
||||
#pragma mark - UI interaction with IBAction
|
||||
|
||||
/// Run helper application to add thyself to startup items.
|
||||
- (IBAction)changeStartOnLogin:(NSButton *)sender {
|
||||
// launchctl list | grep de.relikd
|
||||
CFStringRef helperIdentifier = CFBridgingRetain(@"de.relikd.baRSS-Helper");
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
@implementation UserPrefs
|
||||
|
||||
/// @return @c YES if key is not set. Otherwise, return user defaults property from plist.
|
||||
+ (BOOL)defaultYES:(NSString*)key {
|
||||
if ([[NSUserDefaults standardUserDefaults] objectForKey:key] == NULL) {
|
||||
return YES;
|
||||
@@ -31,14 +32,17 @@
|
||||
return [[NSUserDefaults standardUserDefaults] boolForKey:key];
|
||||
}
|
||||
|
||||
/// @return @c NO if key is not set. Otherwise, return user defaults property from plist.
|
||||
+ (BOOL)defaultNO:(NSString*)key {
|
||||
return [[NSUserDefaults standardUserDefaults] boolForKey:key];
|
||||
}
|
||||
|
||||
/// @return User configured custom browser. Or @c nil if not set yet. (which will fallback to default browser)
|
||||
+ (NSString*)getHttpApplication {
|
||||
return [[NSUserDefaults standardUserDefaults] stringForKey:@"defaultHttpApplication"];
|
||||
}
|
||||
|
||||
/// Store custom browser bundle id to user defaults.
|
||||
+ (void)setHttpApplication:(NSString*)bundleID {
|
||||
[[NSUserDefaults standardUserDefaults] setObject:bundleID forKey:@"defaultHttpApplication"];
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
@interface ModalSheet : NSPanel
|
||||
@property (readonly) BOOL closeInitiated;
|
||||
|
||||
+ (instancetype)modalWithView:(NSView*)content;
|
||||
- (void)setDoneEnabled:(BOOL)accept;
|
||||
@end
|
||||
|
||||
@@ -27,12 +27,22 @@
|
||||
@end
|
||||
|
||||
@implementation ModalSheet
|
||||
@synthesize closeInitiated = _closeInitiated;
|
||||
|
||||
/// User did click the 'Done' button.
|
||||
- (void)didTapDoneButton:(id)sender { [self closeWithResponse:NSModalResponseOK]; }
|
||||
/// User did click the 'Cancel' button.
|
||||
- (void)didTapCancelButton:(id)sender { [self closeWithResponse:NSModalResponseAbort]; }
|
||||
/// Manually disable 'Done' button if a task is still running.
|
||||
- (void)setDoneEnabled:(BOOL)accept { self.btnDone.enabled = accept; }
|
||||
|
||||
/**
|
||||
Called after user has clicked the 'Done' (Return) or 'Cancel' (Esc) button.
|
||||
Flags controller as being closed @c .closeInitiated @c = @c YES.
|
||||
And removes all subviews (clean up).
|
||||
*/
|
||||
- (void)closeWithResponse:(NSModalResponse)response {
|
||||
_closeInitiated = YES;
|
||||
// store modal view width and remove subviews to avoid _NSKeyboardFocusClipView issues
|
||||
// first object is always the view of the modal dialog
|
||||
CGFloat w = self.contentView.subviews.firstObject.frame.size.width;
|
||||
@@ -41,6 +51,12 @@
|
||||
[self.sheetParent endSheet:self returnCode:response];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
Designated initializer for @c ModalSheet.
|
||||
|
||||
@param content @c NSView will be displayed in dialog box. 'Done' and 'Cancel' button will be added automatically.
|
||||
*/
|
||||
+ (instancetype)modalWithView:(NSView*)content {
|
||||
static const int padWindow = 20;
|
||||
static const int padButtons = 12;
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
|
||||
@implementation Preferences
|
||||
|
||||
/// Restore tab selection from previous session
|
||||
- (void)windowDidLoad {
|
||||
[super windowDidLoad];
|
||||
NSUInteger idx = (NSUInteger)[[NSUserDefaults standardUserDefaults] integerForKey:@"preferencesTab"];
|
||||
@@ -40,6 +41,7 @@
|
||||
[self tabClicked:self.window.toolbar.items[idx]];
|
||||
}
|
||||
|
||||
/// Replace content view according to selected tab
|
||||
- (IBAction)tabClicked:(NSToolbarItem *)sender {
|
||||
self.window.contentView = nil;
|
||||
if ([sender.itemIdentifier isEqualToString:@"tabGeneral"]) {
|
||||
@@ -59,7 +61,7 @@
|
||||
@end
|
||||
|
||||
|
||||
|
||||
/// A window that does not respond to Cmd-C, Cmd-Z, Cmd-Shift-Z and Enter-pressed events.
|
||||
@interface NonRespondingWindow : NSWindow
|
||||
@end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user