diff --git a/CHANGELOG.md b/CHANGELOG.md index 95fc50f..d58f223 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ and this project does adhere to [Semantic Versioning](https://semver.org/spec/v2 - *UI:* Accessibility hints for most UI elements - *UI*: Show welcome message upon first usage (empty db) - Welcome message also adds Github releases feed -- Config URL scheme `barss:` with `open/preferences` and `config/fixcache` +- Config URL scheme `barss:` with `open/preferences`, `config/fixcache`, and `backup/show` ### Fixed - *Adding feed:* Show proper HTTP status code error message (4xx and 5xx) diff --git a/baRSS/AppHook.m b/baRSS/AppHook.m index 3245049..3044c2d 100644 --- a/baRSS/AppHook.m +++ b/baRSS/AppHook.m @@ -28,11 +28,13 @@ #import "BarStatusItem.h" #import "UpdateScheduler.h" #import "StoreCoordinator.h" +#import "OpmlFile.h" #import "SettingsFeeds+DragDrop.h" #import "NSURL+Ext.h" +#import "NSDate+Ext.h" #import "NSError+Ext.h" -@interface AppHook() +@interface AppHook() @property (strong) NSWindowController *prefWindow; @end @@ -203,6 +205,7 @@ @textblock barss:open/preferences[/0-4] barss:config/fixcache[/silent] + barss:backup[/show] @/textblock */ - (void)handleConfigURLScheme:(const NSString*)action parameters:(NSArray*)params { @@ -215,9 +218,24 @@ if ([params.firstObject isEqualToString:kURLParamFixCache]) { [StoreCoordinator cleanupAndShowAlert:![params.lastObject isEqualToString:kURLParamSilent]]; } + } else if ([action isEqualToString:kURLActionBackup]) { + NSURL *baseURL = [NSURL backupPathURL]; + [baseURL mkdir]; // non destructive make dir + NSURL *dest = [baseURL file:[@"feeds_" stringByAppendingString:[NSDate dayStringISO8601]] ext:@"opml"]; + NSURL *sym = [baseURL file:@"feeds_latest" ext:@"opml"]; + [sym remove]; // remove old sym link, otherwise won't be updated + [[NSFileManager defaultManager] createSymbolicLinkAtURL:sym withDestinationURL:[NSURL URLWithString:dest.lastPathComponent] error:nil]; + [[OpmlFileExport withDelegate:self] writeOPMLFile:dest withOptions:OpmlFileExportOptionFullBackup]; + if ([params.firstObject isEqualToString:kURLParamShow]) + [[NSWorkspace sharedWorkspace] activateFileViewerSelectingURLs:@[dest]]; } } +/// Callback method for OPML backup export +- (NSArray*)opmlFileExportListOfFeedGroups:(OpmlFileExportOptions)options { + return [StoreCoordinator sortedFeedGroupsWithParent:nil inContext:nil]; +} + #pragma mark - Event Handling, Forward Send Key Down Events diff --git a/baRSS/Constants.h b/baRSS/Constants.h index 915e4b4..0935823 100644 --- a/baRSS/Constants.h +++ b/baRSS/Constants.h @@ -120,12 +120,16 @@ static NSString* const kURLSchemeBarss = @"barss"; static NSString* const kURLActionOpen = @"open"; /// Use @c barss:config to perform configuration steps static NSString* const kURLActionConfig = @"config"; +/// Use @c barss:backup to backup opml file into container +static NSString* const kURLActionBackup = @"backup"; /// Open preferences window with optional tab index. E.g., @c barss:open/preferences/1 static NSString* const kURLParamPreferences = @"preferences"; /// Run core data cleanup with optional silent parameter. E.g., @c barss:config/fixcache/silent static NSString* const kURLParamFixCache = @"fixcache"; /// Disables error alerts and other interactive UI static NSString* const kURLParamSilent = @"silent"; +/// Show backup directory in Finder. E.g., @c barss:backup/show +static NSString* const kURLParamShow = @"show"; #pragma mark - Internal diff --git a/baRSS/Core Data/Feed+Ext.m b/baRSS/Core Data/Feed+Ext.m index cfe4547..71438c7 100644 --- a/baRSS/Core Data/Feed+Ext.m +++ b/baRSS/Core Data/Feed+Ext.m @@ -209,8 +209,7 @@ /// Image file path at e.g., "Application Support/baRSS/favicons/p42". @warning File may not exist! - (NSURL*)iconPath { - NSString *pk = self.objectID.URIRepresentation.lastPathComponent; - return [[NSURL faviconsCacheURL] URLByAppendingPathComponent:pk isDirectory:NO]; + return [[NSURL faviconsCacheURL] file:self.objectID.URIRepresentation.lastPathComponent ext:nil]; } /// Move favicon from @c $TMPDIR to permanent destination in Application Support. diff --git a/baRSS/Core Data/StoreCoordinator.h b/baRSS/Core Data/StoreCoordinator.h index cdbf45b..f3f2f6e 100644 --- a/baRSS/Core Data/StoreCoordinator.h +++ b/baRSS/Core Data/StoreCoordinator.h @@ -27,6 +27,7 @@ static int const dbFileVersion = 1; // update in case database structure changes @interface StoreCoordinator : NSObject // Managing contexts ++ (NSManagedObjectContext*)getMainContext; + (NSManagedObjectContext*)createChildContext; + (void)saveContext:(NSManagedObjectContext*)context andParent:(BOOL)flag; @@ -36,7 +37,7 @@ static int const dbFileVersion = 1; // update in case database structure changes // Feed update + (NSDate*)nextScheduledUpdate; -+ (NSArray*)listOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc; ++ (NSArray*)listOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(nullable NSManagedObjectContext*)moc; // Count elements + (BOOL)isEmpty; @@ -45,10 +46,8 @@ static int const dbFileVersion = 1; // update in case database structure changes + (NSArray*)countAggregatedUnread; // Get List Of Elements -+ (NSArray*)sortedFeedGroupsWithParent:(id)parent inContext:(NSManagedObjectContext*)moc; -+ (NSArray*)sortedArticlesWithParent:(id)parent inContext:(NSManagedObjectContext*)moc; -+ (NSArray*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc; -+ (Feed*)feedWithIndexPath:(nonnull NSString*)path inContext:(NSManagedObjectContext*)moc; ++ (NSArray*)sortedFeedGroupsWithParent:(id)parent inContext:(nullable NSManagedObjectContext*)moc; ++ (Feed*)feedWithIndexPath:(nonnull NSString*)path inContext:(nullable NSManagedObjectContext*)moc; + (NSString*)urlForFeedWithIndexPath:(nonnull NSString*)path; // Unread articles list & mark articled read diff --git a/baRSS/Core Data/StoreCoordinator.m b/baRSS/Core Data/StoreCoordinator.m index 06d8600..69f65ac 100644 --- a/baRSS/Core Data/StoreCoordinator.m +++ b/baRSS/Core Data/StoreCoordinator.m @@ -98,14 +98,15 @@ List of @c Feed items that need to be updated. Scheduled time is now (or in past). @param forceAll If @c YES get a list of all @c Feed regardless of schedules time. + @param moc If @c nil perform requests on main context (ok for reading). */ -+ (NSArray*)listOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc { ++ (NSArray*)listOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(nullable NSManagedObjectContext*)moc { NSFetchRequest *fr = [Feed fetchRequest]; if (!forceAll) { // when fetching also get those feeds that would need update soon (now + 2s) [fr where:@"meta.scheduled <= %@", [NSDate dateWithTimeIntervalSinceNow:+2]]; } - return [fr fetchAllRows:moc]; + return [fr fetchAllRows:moc ? moc : [self getMainContext]]; } @@ -139,24 +140,30 @@ #pragma mark - Get List Of Elements -/// @return Sorted list of @c FeedGroup items where @c FeedGroup.parent @c = @c parent. -+ (NSArray*)sortedFeedGroupsWithParent:(id)parent inContext:(NSManagedObjectContext*)moc { - return [[[[FeedGroup fetchRequest] where:@"parent = %@", parent] sortASC:@"sortIndex"] fetchAllRows:moc]; +/** + @param moc If @c nil perform requests on main context (ok for reading). + @return Sorted list of @c FeedGroup items where @c FeedGroup.parent @c = @c parent. + */ ++ (NSArray*)sortedFeedGroupsWithParent:(id)parent inContext:(nullable NSManagedObjectContext*)moc { + return [[[[FeedGroup fetchRequest] where:@"parent = %@", parent] sortASC:@"sortIndex"] fetchAllRows:moc ? moc : [self getMainContext]]; } /// @return Sorted list of @c FeedArticle items where @c FeedArticle.feed @c = @c parent. -+ (NSArray*)sortedArticlesWithParent:(id)parent inContext:(NSManagedObjectContext*)moc { - return [[[[FeedArticle fetchRequest] where:@"feed = %@", parent] sortDESC:@"sortIndex"] fetchAllRows:moc]; -} +//+ (NSArray*)sortedArticlesWithParent:(id)parent inContext:(NSManagedObjectContext*)moc { +// return [[[[FeedArticle fetchRequest] where:@"feed = %@", parent] sortDESC:@"sortIndex"] fetchAllRows:moc]; +//} /// @return Unsorted list of @c Feed items where @c articles.count @c == @c 0. -+ (NSArray*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc { - return [[[Feed fetchRequest] where:@"articles.@count == 0"] fetchAllRows:moc]; -} +//+ (NSArray*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc { +// return [[[Feed fetchRequest] where:@"articles.@count == 0"] fetchAllRows:moc]; +//} -/// @return Single @c Feed item where @c Feed.indexPath @c = @c path. -+ (Feed*)feedWithIndexPath:(nonnull NSString*)path inContext:(NSManagedObjectContext*)moc { - return [[[Feed fetchRequest] where:@"indexPath = %@", path] fetchFirst:moc]; +/** + @param moc If @c nil perform requests on main context (ok for reading). + @return Single @c Feed item where @c Feed.indexPath @c = @c path. + */ ++ (Feed*)feedWithIndexPath:(nonnull NSString*)path inContext:(nullable NSManagedObjectContext*)moc { + return [[[Feed fetchRequest] where:@"indexPath = %@", path] fetchFirst:moc ? moc : [self getMainContext]]; } /// @return URL of @c Feed item where @c Feed.indexPath @c = @c path. diff --git a/baRSS/Feed Import/FaviconDownload.m b/baRSS/Feed Import/FaviconDownload.m index be84d44..c3ceb72 100644 --- a/baRSS/Feed Import/FaviconDownload.m +++ b/baRSS/Feed Import/FaviconDownload.m @@ -24,6 +24,7 @@ #import "FaviconDownload.h" #import "Feed+Ext.h" #import "FeedMeta+Ext.h" +#import "NSURL+Ext.h" #import "NSURLRequest+Ext.h" @interface FaviconDownload() @@ -148,12 +149,10 @@ if (error) path = nil; // will also nullify img NSImage *img = path ? [[NSImage alloc] initByReferencingURL:path] : nil; if (img.valid) { - NSString *tmp = NSProcessInfo.processInfo.globallyUniqueString; - NSURL *dest = [path URLByDeletingLastPathComponent]; - dest = [dest URLByAppendingPathComponent:tmp isDirectory:NO]; // move image to temporary destination, otherwise dataTask: will delete it. - [[NSFileManager defaultManager] moveItemAtURL:path toURL:dest error:nil]; - self.fileURL = dest; + NSString *tmpFile = NSProcessInfo.processInfo.globallyUniqueString; + self.fileURL = [[path URLByDeletingLastPathComponent] file:tmpFile ext:nil]; + [path moveTo:self.fileURL]; } else if (self.hostURL) { [self loadImageFromDefaultLocation]; // starts a new request return; diff --git a/baRSS/Feed Import/OpmlFile.m b/baRSS/Feed Import/OpmlFile.m index 54b6529..0da09dc 100644 --- a/baRSS/Feed Import/OpmlFile.m +++ b/baRSS/Feed Import/OpmlFile.m @@ -262,7 +262,7 @@ static NSInteger RadioGroupSelection(NSView *view) { NSXMLElement *head = [NSXMLElement elementWithName:@"head"]; head.children = @[[NSXMLElement elementWithName:@"title" stringValue:@"baRSS feeds"], [NSXMLElement elementWithName:@"ownerName" stringValue:@"baRSS"], - [NSXMLElement elementWithName:@"dateCreated" stringValue:[NSDate dayStringISO8601]] ]; + [NSXMLElement elementWithName:@"dateCreated" stringValue:[NSDate timeStringISO8601]] ]; NSXMLElement *body = [NSXMLElement elementWithName:@"body"]; for (FeedGroup *item in list) { diff --git a/baRSS/Feed Import/UpdateScheduler.m b/baRSS/Feed Import/UpdateScheduler.m index a0098b3..5e2be80 100644 --- a/baRSS/Feed Import/UpdateScheduler.m +++ b/baRSS/Feed Import/UpdateScheduler.m @@ -164,10 +164,8 @@ static _Atomic(NSUInteger) _queueSize = 0; /// Perform @c FaviconDownload on all core data @c Feed entries. + (void)updateAllFavicons { - NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; - for (Feed *f in [StoreCoordinator listOfFeedsThatNeedUpdate:YES inContext:moc]) + for (Feed *f in [StoreCoordinator listOfFeedsThatNeedUpdate:YES inContext:nil]) [FaviconDownload updateFeed:f finally:nil]; - [moc reset]; } /// Download list of feeds. Either silently in background or with alerts in foreground. diff --git a/baRSS/Info.plist b/baRSS/Info.plist index a65f6d3..e6c2b05 100644 --- a/baRSS/Info.plist +++ b/baRSS/Info.plist @@ -70,7 +70,7 @@ CFBundleVersion - 13460 + 13607 LSApplicationCategoryType public.app-category.news LSMinimumSystemVersion diff --git a/baRSS/NSCategories/NSDate+Ext.h b/baRSS/NSCategories/NSDate+Ext.h index 0fb3853..1d8e320 100644 --- a/baRSS/NSCategories/NSDate+Ext.h +++ b/baRSS/NSCategories/NSDate+Ext.h @@ -33,6 +33,7 @@ typedef NS_ENUM(int32_t, TimeUnitType) { }; @interface NSDate (Ext) ++ (NSString*)timeStringISO8601; + (NSString*)dayStringISO8601; + (NSString*)dayStringLocalized; @end diff --git a/baRSS/NSCategories/NSDate+Ext.m b/baRSS/NSCategories/NSDate+Ext.m index 4451bd4..af6d293 100644 --- a/baRSS/NSCategories/NSDate+Ext.m +++ b/baRSS/NSCategories/NSDate+Ext.m @@ -35,11 +35,15 @@ static TimeUnitType const _values[] = { @implementation NSDate (Ext) -/// @return Day as string in iso format: @c YYYY-MM-DD'T'hh:mm:ss'Z' -+ (NSString*)dayStringISO8601 { +/// @return Time as string in iso format: @c YYYY-MM-DD'T'hh:mm:ss'Z' ++ (NSString*)timeStringISO8601 { return [[[NSISO8601DateFormatter alloc] init] stringFromDate:[NSDate date]]; -// NSDateComponents *now = [[NSCalendar currentCalendar] components:NSCalendarUnitDay | NSCalendarUnitMonth | NSCalendarUnitYear fromDate:[NSDate date]]; -// return [NSString stringWithFormat:@"%04ld-%02ld-%02ld", now.year, now.month, now.day]; +} + +/// @return Day as string in iso format: @c YYYY-MM-DD ++ (NSString*)dayStringISO8601 { + NSDateComponents *now = [[NSCalendar currentCalendar] components:NSCalendarUnitDay | NSCalendarUnitMonth | NSCalendarUnitYear fromDate:[NSDate date]]; + return [NSString stringWithFormat:@"%04ld-%02ld-%02ld", now.year, now.month, now.day]; } /// @return Day as string in localized short format, e.g., @c DD.MM.YY diff --git a/baRSS/NSCategories/NSURL+Ext.h b/baRSS/NSCategories/NSURL+Ext.h index 0f19451..b94525c 100644 --- a/baRSS/NSCategories/NSURL+Ext.h +++ b/baRSS/NSCategories/NSURL+Ext.h @@ -23,8 +23,15 @@ @import Cocoa; @interface NSURL (Ext) +// Generators ++ (NSURL*)applicationSupportURL; + (NSURL*)faviconsCacheURL; ++ (NSURL*)backupPathURL; +// File Traversal - (BOOL)existsAndIsDir:(BOOL)dir; +- (NSURL*)subdir:(NSString*)dirname; +- (NSURL*)file:(NSString*)filename ext:(nullable NSString*)ext; +// File Manipulation - (BOOL)mkdir; - (void)remove; - (void)moveTo:(NSURL*)destination; diff --git a/baRSS/NSCategories/NSURL+Ext.m b/baRSS/NSCategories/NSURL+Ext.m index d164f44..7a45bb8 100644 --- a/baRSS/NSCategories/NSURL+Ext.m +++ b/baRSS/NSCategories/NSURL+Ext.m @@ -26,24 +26,56 @@ @implementation NSURL (Ext) -/// @return Directory URL pointing to "Application Support/baRSS/favicons". Does @b not create directory! -+ (NSURL*)faviconsCacheURL { +// --------------------------------------------------------------- +// | MARK: - Generators +// --------------------------------------------------------------- + +/// @return Directory URL pointing to "Application Support/baRSS". Does @b not create directory! ++ (NSURL*)applicationSupportURL { static NSURL *path = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ path = [[NSFileManager defaultManager] URLForDirectory:NSApplicationSupportDirectory inDomain:NSUserDomainMask appropriateForURL:nil create:YES error:nil]; path = [path URLByAppendingPathComponent:[UserPrefs appName] isDirectory:YES]; - path = [path URLByAppendingPathComponent:@"favicons" isDirectory:YES]; }); return path; } +/// @return Directory URL pointing to "Application Support/baRSS/favicons". Does @b not create directory! ++ (NSURL*)faviconsCacheURL { + return [[self applicationSupportURL] URLByAppendingPathComponent:@"favicons" isDirectory:YES]; +} + +/// @return Directory URL pointing to "Application Support/baRSS/backup". Does @b not create directory! ++ (NSURL*)backupPathURL { + return [[self applicationSupportURL] URLByAppendingPathComponent:@"backup" isDirectory:YES]; +} + +// --------------------------------------------------------------- +// | MARK: - File Traversal +// --------------------------------------------------------------- + /// @return @c YES if and only if item exists at URL and item matches @c dir flag - (BOOL)existsAndIsDir:(BOOL)dir { BOOL d; return self.path && [[NSFileManager defaultManager] fileExistsAtPath:self.path isDirectory:&d] && d == dir; } +/// @return @c NSURL copy with appended directory path +- (NSURL*)subdir:(NSString*)dirname { + return [self URLByAppendingPathComponent:dirname isDirectory:YES]; +} + +/// @return @c NSURL copy with appended file path and extension +- (NSURL*)file:(NSString*)filename ext:(nullable NSString*)ext { + NSURL *u = [self URLByAppendingPathComponent:filename isDirectory:NO]; + return ext.length > 0 ? [u URLByAppendingPathExtension:ext] : u; +} + +// --------------------------------------------------------------- +// | MARK: - File Manipulation +// --------------------------------------------------------------- + /** Create directory at URL. If directory exists, this method does nothing. @return @c YES if dir created successfully. @c NO if dir already exists or an error occured. diff --git a/baRSS/Preferences/About Tab/SettingsAboutView.m b/baRSS/Preferences/About Tab/SettingsAboutView.m index 5344089..8c6a73b 100644 --- a/baRSS/Preferences/About Tab/SettingsAboutView.m +++ b/baRSS/Preferences/About Tab/SettingsAboutView.m @@ -69,7 +69,8 @@ [self str:mas add:@"RSXML2" link:@"https://github.com/relikd/RSXML2"]; [self str:mas add:@" (MIT License)" bold:NO]; [self str:mas add:@"\n\n\n\nOptions\n" bold:YES]; - [self str:mas add:@"Fix Cache" link:@"barss:config/fixcache"]; + [self str:mas add:@"Fix Cache\n" link:@"barss:config/fixcache"]; + [self str:mas add:@"Backup now\n" link:@"barss:backup/show"]; [mas endEditing]; return mas; } diff --git a/baRSS/Status Bar Menu/BarMenu.m b/baRSS/Status Bar Menu/BarMenu.m index 1ed91e6..77d25c9 100644 --- a/baRSS/Status Bar Menu/BarMenu.m +++ b/baRSS/Status Bar Menu/BarMenu.m @@ -69,19 +69,17 @@ /// Populate menu with items. - (void)menuNeedsUpdate:(NSMenu*)menu { - NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; if (menu.isFeedMenu) { - Feed *feed = [StoreCoordinator feedWithIndexPath:menu.titleIndexPath inContext:moc]; + Feed *feed = [StoreCoordinator feedWithIndexPath:menu.titleIndexPath inContext:nil]; [self setArticles:[feed sortedArticles] forMenu:menu]; } else { - NSArray *groups = [StoreCoordinator sortedFeedGroupsWithParent:menu.parentItem.representedObject inContext:moc]; + NSArray *groups = [StoreCoordinator sortedFeedGroupsWithParent:menu.parentItem.representedObject inContext:nil]; if (groups.count == 0) { [menu addItemWithTitle:NSLocalizedString(@"~~~ no entries ~~~", nil) action:nil keyEquivalent:@""].enabled = NO; } else { [self setFeedGroups:groups forMenu:menu]; } } - [moc reset]; } /// Get rid of everything that is not needed. @@ -128,13 +126,11 @@ @warning @c item and @c feed will often mismatch. */ - (void)updateFeedMenuItem:(NSManagedObjectID*)oid withBlock:(void(^)(Feed *feed, NSMenuItem *item))block { - NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; - Feed *feed = [moc objectWithID:oid]; + Feed *feed = [[StoreCoordinator getMainContext] objectWithID:oid]; if ([feed isKindOfClass:[Feed class]]) { NSMenuItem *item = [self.statusItem.mainMenu deepestItemWithPath:feed.indexPath]; if (item) block(feed, item); } - [moc reset]; } /// Callback method fired when feed has been updated in the background.