Files
baRSS/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m
2025-06-24 15:36:45 +02:00

390 lines
15 KiB
Objective-C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@import RSXML2;
#import "ModalFeedEdit.h"
#import "ModalFeedEditView.h"
#import "RefreshStatisticsView.h"
#import "Constants.h"
#import "FeedDownload.h"
#import "FaviconDownload.h"
#import "Feed+Ext.h"
#import "FeedMeta+Ext.h"
#import "FeedGroup+Ext.h"
#import "NSView+Ext.h"
#import "NSDate+Ext.h"
#import "NSURL+Ext.h"
#import "RegexConverterController.h"
#import "RegexConverterModal.h"
#import "RegexConverter+Ext.h"
// ################################################################
// #
// # MARK: - ModalEditDialog -
// #
// ################################################################
@interface ModalEditDialog() <NSWindowDelegate>
@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 alloc] initWithView:self.view];
self.modalSheet.delegate = self;
}
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
// ################################################################
// #
// # MARK: - ModalFeedEdit -
// #
// ################################################################
@interface ModalFeedEdit() <FeedDownloadDelegate, RefreshIntervalButtonDelegate, FaviconDownloadDelegate>
@property (strong) IBOutlet ModalFeedEditView *view; // override
@property (copy) NSString *previousURL; // check if changed and avoid multiple download
@property (strong) NSURL *faviconFile;
@property (strong) FeedDownload *memFeed;
@property (weak) FaviconDownload *memIcon;
@property (strong) RefreshStatisticsView *statisticsView;
@property (nonatomic, assign) BOOL skipIconDownload;
@property (nonatomic, assign) BOOL openRegexAfterDownload;
@property (weak) id eventMonitor;
@end
@implementation ModalFeedEdit
@dynamic view;
/// Init feed edit dialog with default values.
- (void)loadView {
self.view = [[ModalFeedEditView alloc] initWithController:self];
self.previousURL = @"";
self.view.refreshNum.intValue = 30;
[NSDate populateUnitsMenu:self.view.refreshUnit selected:TimeUnitMinutes];
[self populateTextFields:self.feedGroup];
// removed in windowShouldClose:
self.eventMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskFlagsChanged handler:^(NSEvent *event) {
BOOL optionKeyActive = ((event.modifierFlags & NSEventModifierFlagOption) != 0);
self.view.regexConverterButton.hidden = !self.feedGroup.feed.regex && !optionKeyActive;
return event;
}];
}
/// 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.view.name.objectValue = fg.name; // user given feed title
self.view.name.placeholderString = fg.feed.title; // actual feed title
self.view.url.objectValue = fg.feed.meta.url;
self.previousURL = self.view.url.stringValue;
self.view.favicon.image = [fg.feed iconImage16];
self.view.regexConverterButton.hidden = !fg.feed.regex;
[NSDate setInterval:fg.feed.meta.refresh forPopup:self.view.refreshUnit andField:self.view.refreshNum animate:NO];
[self statsForCoreDataObject];
}
- (void)dealloc {
[self.faviconFile remove]; // Delete temporary favicon (if still exists)
}
#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 {
Feed *f = self.feedGroup.feed;
Interval intv = [NSDate intervalForPopup:self.view.refreshUnit andField:self.view.refreshNum];
[self.feedGroup setNameIfChanged:self.view.name.stringValue];
[f.meta setRefreshIfChanged:intv];
if (self.memFeed) {
[self.memFeed copyValuesTo:f ignoreError:YES];
if (self.faviconFile) // only if downloaded anything (nil deletes icon!)
[f setNewIcon:self.faviconFile];
self.faviconFile = nil;
}
}
/// Cancel any running download task and free volatile variables
- (void)cancelDownloads {
[self.memFeed cancel]; self.memFeed = nil;
[self.memIcon cancel]; self.memIcon = nil;
[self.faviconFile remove]; self.faviconFile = nil;
}
/**
Prepare UI (nullify results and start @c ProgressIndicator ).
Also disable 'Done' button during download and re-enable after download is finished.
*/
- (void)downloadRSS {
[self cancelDownloads];
[self.modalSheet setDoneEnabled:NO]; // prevent user from closing the dialog during download
[self.view.spinnerName startAnimation:nil];
if (!self.skipIconDownload) {
[self.view.spinnerURL startAnimation:nil];
self.view.favicon.image = nil;
}
self.view.warningButton.hidden = YES;
// User didn't change title since last fetch. Will be pre-filled with new title after download
if ([self.view.name.stringValue isEqualToString:self.view.name.placeholderString]) {
self.view.name.stringValue = @"";
self.view.name.placeholderString = NSLocalizedString(@"Loading …", nil);
}
self.previousURL = self.view.url.stringValue;
self.memFeed = [[[FeedDownload withURL:self.previousURL]
withRegex:self.feedGroup.feed.regex enforce:self.openRegexAfterDownload]
startWithDelegate:self];
}
/**
If entered URL happens to be a normal webpage, @c RSXML will parse all suitable feed links.
Present this list to the user and let her decide which one it should be.
@return Either URL string or @c nil if user canceled the selection.
*/
- (nullable NSString*)feedDownload:(FeedDownload*)sender selectFeedFromList:(NSArray<RSHTMLMetadataFeedLink*>*)list {
NSMenu *menu = [[NSMenu alloc] initWithTitle:NSLocalizedString(@"Choose feed menu", nil)];
menu.autoenablesItems = NO;
for (RSHTMLMetadataFeedLink *fl in list) {
[menu addItemWithTitle:fl.title action:nil keyEquivalent:@""];
}
NSPoint belowURL = NSMakePoint(0, NSHeight(self.view.url.frame));
if ([menu popUpMenuPositioningItem:nil atLocation:belowURL inView:self.view.url]) {
NSInteger idx = [menu indexOfItem:menu.highlightedItem];
if (idx < 0) idx = 0; // User hit enter without selection. Assume first item, because PopUpMenu did return YES!
return [list objectAtIndex:(NSUInteger)idx].link;
}
return nil; // user selection canceled
}
/// If URL was redirected, replace original text field value with new one. (e.g., https redirect)
- (void)feedDownload:(FeedDownload*)sender urlRedirected:(NSString*)newURL {
if (!sender.error) {
// If the url has changed and there is an error:
// This probably means the feed URL was resolved, but the successive download returned 5xx error.
// Presumably to prevent site crawlers accessing many pages in quick succession. (delay of 1s does help)
// By not setting previousURL, a second hit on the 'Done' button will retry the resolved URL again.
self.previousURL = newURL;
}
self.view.url.stringValue = newURL;
}
/// Update UI TextFields with downloaded values. Title updated if TextField is empty, URL if redirect.
- (void)feedDownloadDidFinish:(FeedDownload*)sender {
// Stop spinner for name field but keep running for URL until favicon downloaded
[self.view.spinnerName stopAnimation:nil];
NSString *newTitle = sender.xmlfeed.title;
self.view.name.placeholderString = newTitle;
if (newTitle.length > 0 && self.view.name.stringValue.length == 0) {
self.view.name.stringValue = newTitle; // only if default title wasn't changed
}
// TODO: user preference to automatically select refresh interval (selection: None,min,max,avg,median)
[self statsForDownloadObject:sender.xmlfeed.articles];
BOOL hasError = (sender.error != nil);
self.view.favicon.hidden = hasError;
self.view.warningButton.hidden = !hasError;
// Start favicon download
if (hasError || self.skipIconDownload)
[self downloadComplete];
else
self.memIcon = [[sender faviconDownload] startWithDelegate:self];
}
/**
The last step of the download process.
Stop spinning animation, set favivon image (right of url bar), and re-enable 'Done' button.
*/
- (void)faviconDownload:(FaviconDownload*)sender didFinish:(nullable NSURL*)path {
// Create image from favicon temporary file location or default icon if no favicon exists.
NSImage *img;
if (path) {
NSData* data = [[NSData alloc] initWithContentsOfURL:path];
img = [[NSImage alloc] initWithData:data];
} else {
img = [NSImage imageNamed:RSSImageDefaultRSSIcon];
}
self.view.favicon.image = img;
self.faviconFile = path;
[self downloadComplete];
}
/// Called regardless of favicon download.
- (void)downloadComplete {
[self.view.spinnerURL stopAnimation:nil];
[self.modalSheet setDoneEnabled:YES];
self.skipIconDownload = NO;
if (self.openRegexAfterDownload) {
[self openRegexConverter];
}
}
#pragma mark - Regex Converter
- (void)openRegexConverter {
if (!self.openRegexAfterDownload) {
self.openRegexAfterDownload = YES;
self.skipIconDownload = self.feedGroup.feed.hasIcon;
[self downloadRSS];
return;
}
self.openRegexAfterDownload = NO;
// shrink FeedEdit modal size to effectively hide it behind new modal
NSRect previous = self.modalSheet.frame;
CGFloat minWidthDiff = previous.size.width - self.modalSheet.minSize.width;
[self.modalSheet setFrame:NSInsetRect(previous, minWidthDiff / 2.0, 0) display:NO];
RegexConverterController *c = [RegexConverterController withData:self.memFeed.rawData andConverter:self.feedGroup.feed.regex];
[self.modalSheet.sheetParent beginCriticalSheet:[c getModalSheet] completionHandler:^(NSModalResponse returnCode) {
// reset previous size
[self.modalSheet setFrame:previous display:NO];
if (returnCode == NSModalResponseOK) {
[c applyChanges:self.feedGroup.feed];
self.skipIconDownload = self.feedGroup.feed.hasIcon;
self.view.regexConverterButton.hidden = !self.feedGroup.feed.regex;
[self downloadRSS];
} else {
[self populateTextFields:self.feedGroup];
}
}];
}
#pragma mark - Feed Statistics
/// Perform statistics on newly downloaded feed item
- (void)statsForDownloadObject:(NSArray<RSParsedArticle*>*)articles {
NSMutableArray<NSDate*> *arr = [NSMutableArray arrayWithCapacity:articles.count];
for (RSParsedArticle *a in articles) {
NSDate *d = a.datePublished;
if (!d) d = a.dateModified;
if (!d) continue;
[arr addObject:d];
}
[self appendViewWithFeedStatistics:arr count:articles.count];
}
/// Perform statistics on stored core data object
- (void)statsForCoreDataObject {
NSArray<FeedArticle*> *articles = [self.feedGroup.feed sortedArticles];
[self appendViewWithFeedStatistics:[articles valueForKeyPath:@"published"] count:articles.count];
}
/// Generate statistics UI with buttons to quickly select refresh unit and duration.
- (void)appendViewWithFeedStatistics:(NSArray*)dates count:(NSUInteger)count {
CGFloat prevHeight = 0.f;
if (self.statisticsView != nil) {
prevHeight = NSHeight(self.statisticsView.frame) + PAD_L;
[self.statisticsView removeFromSuperview];
self.statisticsView = nil;
}
NSDictionary *stats = [NSDate refreshIntervalStatistics:dates];
RefreshStatisticsView *rsv = [[RefreshStatisticsView alloc] initWithRefreshInterval:stats articleCount:count callback:self];
[[self getModalSheet] extendContentViewBy:NSHeight(rsv.frame) + PAD_L - prevHeight];
self.statisticsView = [rsv placeIn:self.view x:CENTER y:0];
}
/// Callback method @c RefreshStatisticsView
- (void)refreshIntervalButtonClicked:(NSButton *)sender {
[NSDate setInterval:(Interval)sender.tag forPopup:self.view.refreshUnit andField:self.view.refreshNum animate:YES];
}
#pragma mark - NSTextField Delegate
/// Window delegate will be only called on button 'Done'.
- (BOOL)windowShouldClose:(ModalSheet*)sender {
if (sender.didTapCancel) {
[self cancelDownloads];
} else if (![self.previousURL isEqualToString:self.view.url.stringValue]) { // 'Done' button
[[NSNotificationCenter defaultCenter] postNotificationName:NSControlTextDidEndEditingNotification object:self.view.url];
return NO;
}
[NSEvent removeMonitor:self.eventMonitor];
return YES;
}
/// Whenever the user finished entering the url (return key or focus change) perform a download request.
- (void)controlTextDidEndEditing:(NSNotification*)obj {
if (obj.object == self.view.url && !self.modalSheet.didTapCancel) {
if (![self.previousURL isEqualToString:self.view.url.stringValue]) {
[self downloadRSS];
}
}
}
/// Warning button next to url text field. Will be visible if an error occurs during download.
- (void)didClickWarningButton:(NSButton*)sender {
NSError *err = self.memFeed.error;
if (!err) return;
// show reload button if server is temporarily offline (any 5xx server error)
BOOL serverError = (err.code == NSURLErrorBadServerResponse && err.domain == NSURLErrorDomain);
self.view.warningReload.hidden = !serverError;
// set error description as text
if (serverError)
self.view.warningText.stringValue = [NSString stringWithFormat:@"%@\n\n%@", err.localizedDescription, err.localizedRecoverySuggestion];
else
self.view.warningText.objectValue = err.localizedDescription;
NSSize newSize = self.view.warningText.fittingSize; // width is limited by the textfield's preferred width
newSize.width += 2 * self.view.warningText.frame.origin.x; // the padding
newSize.height += 2 * self.view.warningText.frame.origin.y;
// apply fitting size and display
self.view.warningPopover.contentSize = newSize;
[self.view.warningPopover showRelativeToRect:NSZeroRect ofView:sender preferredEdge:NSRectEdgeMinY];
}
/// Either hit by Cmd+R or reload button inside warning popover error description
- (void)reloadData {
[self downloadRSS];
}
@end
// ################################################################
// #
// # MARK: - ModalGroupEdit -
// #
// ################################################################
@implementation ModalGroupEdit
/// Init view and set group name if edeting an already existing object.
- (void)viewDidLoad {
[super viewDidLoad];
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 {
self.view = [[NSView inputField:NSLocalizedString(@"New Group Name", nil) width:0] sizeToRight:0];
}
/// Edit of group finished. Save changes to core data object and perform save operation on delegate.
- (void)applyChangesToCoreDataObject {
[self.feedGroup setNameIfChanged:((NSTextField*)self.view).stringValue];
}
@end