342 lines
14 KiB
Objective-C
342 lines
14 KiB
Objective-C
#import "StoreCoordinator.h"
|
|
#import "AppHook.h"
|
|
#import "Constants.h"
|
|
#import "FaviconDownload.h"
|
|
#import "UserPrefs.h"
|
|
#import "Feed+Ext.h"
|
|
#import "FeedArticle+Ext.h"
|
|
#import "NSURL+Ext.h"
|
|
#import "NSError+Ext.h"
|
|
#import "NSFetchRequest+Ext.h"
|
|
|
|
@implementation StoreCoordinator
|
|
|
|
#pragma mark - Managing contexts
|
|
|
|
/// @return The application main persistent context.
|
|
+ (NSManagedObjectContext*)getMainContext {
|
|
return [(AppHook*)NSApp persistentContainer].viewContext;
|
|
}
|
|
|
|
/// New child context with @c NSMainQueueConcurrencyType and without undo manager.
|
|
+ (NSManagedObjectContext*)createChildContext {
|
|
NSManagedObjectContext *context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType];
|
|
[context setParentContext:[self getMainContext]];
|
|
context.undoManager = nil;
|
|
//context.automaticallyMergesChangesFromParent = YES;
|
|
return context;
|
|
}
|
|
|
|
/**
|
|
Commit changes and perform save operation on @c context.
|
|
|
|
@param flag If @c YES save any parent context as well (recursive).
|
|
*/
|
|
+ (void)saveContext:(NSManagedObjectContext*)context andParent:(BOOL)flag {
|
|
if (![context commitEditing])
|
|
NSLogCaller(@"unable to commit editing before saving");
|
|
NSError *error = nil;
|
|
if (context.hasChanges && ![context save:&error])
|
|
[error inCasePresent:NSApp];
|
|
if (flag && context.parentContext)
|
|
[self saveContext:context.parentContext andParent:flag];
|
|
}
|
|
|
|
|
|
#pragma mark - Options
|
|
|
|
|
|
/// @return Value for option with @c key or @c nil.
|
|
+ (nullable NSString*)optionForKey:(NSString*)key {
|
|
return [[[Options fetchRequest] where:@"key = %@", key] fetchFirst:[self getMainContext]].value;
|
|
}
|
|
|
|
/// Init new option with given @c key
|
|
+ (void)setOption:(NSString*)key value:(NSString*)value {
|
|
NSManagedObjectContext *moc = [self getMainContext];
|
|
Options *opt = [[[Options fetchRequest] where:@"key = %@", key] fetchFirst:moc];
|
|
if (!opt) {
|
|
opt = [[Options alloc] initWithEntity:Options.entity insertIntoManagedObjectContext:moc];
|
|
opt.key = key;
|
|
}
|
|
if (opt.value != value) {
|
|
opt.value = value;
|
|
}
|
|
[self saveContext:moc andParent:YES];
|
|
[moc reset];
|
|
}
|
|
|
|
|
|
#pragma mark - Feed Update
|
|
|
|
/// @return @c NSDate of next (earliest) feed update. May be @c nil.
|
|
+ (NSDate*)nextScheduledUpdate {
|
|
NSFetchRequest *fr = [FeedMeta fetchRequest];
|
|
[fr addFunctionExpression:@"min:" onKeyPath:@"scheduled" name:@"minDate" type:NSDateAttributeType];
|
|
return [fr fetchFirstDict: [self getMainContext]][@"minDate"];
|
|
}
|
|
|
|
/**
|
|
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<Feed*>*)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 ? moc : [self getMainContext]];
|
|
}
|
|
|
|
|
|
#pragma mark - Count Elements
|
|
|
|
/// @return @c YES if core data has no stored @c FeedGroup
|
|
+ (BOOL)isEmpty {
|
|
return [[FeedGroup fetchRequest] fetchFirst:[self getMainContext]] == nil;
|
|
}
|
|
|
|
/// @return Sum of all unread @c FeedArticle items.
|
|
+ (NSUInteger)countTotalUnread {
|
|
return [[[FeedArticle fetchRequest] where:@"unread = YES"] fetchCount: [self getMainContext]];
|
|
}
|
|
|
|
/// @return Count of objects at root level. Aka @c sortIndex for the next @c FeedGroup item.
|
|
+ (NSUInteger)countRootItemsInContext:(NSManagedObjectContext*)moc {
|
|
return [[[FeedGroup fetchRequest] where:@"parent = NULL"] fetchCount:moc];
|
|
}
|
|
|
|
/// @return Unread and total count grouped by @c Feed item.
|
|
+ (NSArray<NSDictionary*>*)countAggregatedUnread {
|
|
NSFetchRequest *fr = [Feed fetchRequest];
|
|
fr.propertiesToGroupBy = @[ @"indexPath" ];
|
|
fr.propertiesToFetch = @[ @"indexPath" ];
|
|
[fr addFunctionExpression:@"sum:" onKeyPath:@"articles.unread" name:@"unread" type:NSInteger32AttributeType];
|
|
[fr addFunctionExpression:@"count:" onKeyPath:@"articles.unread" name:@"total" type:NSInteger32AttributeType];
|
|
return (NSArray<NSDictionary*>*)[fr fetchAllRows: [self getMainContext]];
|
|
}
|
|
|
|
|
|
#pragma mark - Get List Of Elements
|
|
|
|
/**
|
|
@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<FeedGroup*>*)sortedFeedGroupsWithParent:(nullable 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<FeedArticle*>*)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<Feed*>*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc {
|
|
// return [[[Feed fetchRequest] where:@"articles.@count == 0"] fetchAllRows: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.
|
|
+ (NSString*)urlForFeedWithIndexPath:(nonnull NSString*)path {
|
|
return [[[[Feed fetchRequest] where:@"indexPath = %@", path] select:@[@"link"]] fetchFirstDict: [self getMainContext]][@"link"];
|
|
}
|
|
|
|
/// @return Unsorted list of object IDs where @c Feed.indexPath begins with @c path @c + @c "."
|
|
+ (NSArray<NSManagedObjectID*>*)feedIDsForIndexPath:(nonnull NSString*)path inContext:(NSManagedObjectContext*)moc {
|
|
return [[[Feed fetchRequest] where:@"indexPath BEGINSWITH %@", [path stringByAppendingString:@"."]] fetchIDs:moc];
|
|
}
|
|
|
|
|
|
#pragma mark - Unread Articles List & Mark Read
|
|
|
|
/// @return Return predicate that will match either exactly one, @b or a list of, @b or all @c Feed items.
|
|
+ (nullable NSPredicate*)predicateWithPath:(nullable NSString*)path isFeed:(BOOL)flag inContext:(NSManagedObjectContext*)moc {
|
|
if (!path) return nil; // match all
|
|
if (flag) {
|
|
Feed *obj = [self feedWithIndexPath:path inContext:moc];
|
|
return [NSPredicate predicateWithFormat:@"feed = %@", obj.objectID];
|
|
}
|
|
NSArray *list = [self feedIDsForIndexPath:path inContext:moc];
|
|
if (list && list.count > 0) {
|
|
return [NSPredicate predicateWithFormat:@"feed IN %@", list];
|
|
}
|
|
return [NSPredicate predicateWithValue:NO]; // match none
|
|
}
|
|
|
|
/**
|
|
Return object list with @c FeedArticle where @c unread @c = @c YES. In the same order the user provided.
|
|
|
|
@param path Match @c Feed items where @c indexPath string matches @c path.
|
|
@param feedFlag If @c YES path must match exactly. If @c NO match items that begin with @c path + @c "."
|
|
@param sortFlag Whether articles should be returned in sorted order (e.g., for 'open all unread').
|
|
@param readFlag Match @c FeedArticle where @c unread @c = @c readFlag.
|
|
@param limit Only return first @c X articles that match the criteria.
|
|
@return Sorted list of @c FeedArticle with @c unread @c = @c YES.
|
|
*/
|
|
+ (NSArray<FeedArticle*>*)articlesAtPath:(nullable NSString*)path isFeed:(BOOL)feedFlag sorted:(BOOL)sortFlag unread:(BOOL)readFlag inContext:(NSManagedObjectContext*)moc limit:(NSUInteger)limit {
|
|
NSFetchRequest<FeedArticle*> *fr = [[FeedArticle fetchRequest] where:@"unread = %d", readFlag];
|
|
fr.fetchLimit = limit;
|
|
if (sortFlag) {
|
|
if (!path || !feedFlag)
|
|
[fr sortASC:@"feed.indexPath"];
|
|
[fr sortDESC:@"sortIndex"];
|
|
}
|
|
/* UNUSED. Batch updates will break NSUndoManager in preferences. Fix that before usage.
|
|
NSBatchUpdateRequest *bur = [NSBatchUpdateRequest batchUpdateRequestWithEntityName: FeedArticle.entity.name];
|
|
bur.propertiesToUpdate = @{ @"unread": @(!readFlag) };
|
|
bur.resultType = NSUpdatedObjectIDsResultType;
|
|
bur.predicate = [NSPredicate predicateWithFormat:@"unread = %d", readFlag];*/
|
|
NSPredicate *feedFilter = [self predicateWithPath:path isFeed:feedFlag inContext:moc];
|
|
if (feedFilter)
|
|
fr.predicate = [NSCompoundPredicate andPredicateWithSubpredicates:@[fr.predicate, feedFilter]];
|
|
return [fr fetchAllRows:moc];
|
|
}
|
|
|
|
/**
|
|
For provided articles, pen link, mark read, and save changes.
|
|
@warning Will invalidate context.
|
|
|
|
@param list Should only contain @c FeedArticle
|
|
@param markRead Whether the articles should be marked read or unread.
|
|
@param openLinks Whether to open the link or mark read without opening
|
|
|
|
@return @c notificationID for all articles that were opened (empty if @c openLinks=NO or open failed).
|
|
*/
|
|
+ (nullable NSArray<NSString*>*)updateArticles:(NSArray<FeedArticle*>*)list markRead:(BOOL)markRead andOpen:(BOOL)openLinks inContext:(NSManagedObjectContext*)moc {
|
|
if (openLinks) {
|
|
NSMutableArray<NSURL*> *urls = [NSMutableArray arrayWithCapacity:list.count];
|
|
for (FeedArticle *fa in list) {
|
|
if (fa.link.length > 0)
|
|
[urls addObject:[NSURL URLWithString:fa.link]];
|
|
}
|
|
if (urls.count > 0 && !UserPrefsOpenURLs(urls))
|
|
return nil; // if success == NO, do not modify unread state & exit
|
|
}
|
|
|
|
NSInteger countChange = 0;
|
|
for (FeedArticle *fa in list) {
|
|
if (fa.unread == markRead) { // only if differs
|
|
fa.unread = !markRead;
|
|
countChange += markRead ? -1 : +1;
|
|
}
|
|
}
|
|
[self saveContext:moc andParent:YES];
|
|
|
|
// gather uri-ids for notification dismiss
|
|
NSMutableArray<NSString*> *dbRefs = [NSMutableArray array];
|
|
if (markRead) {
|
|
for (FeedArticle *fa in list) {
|
|
[dbRefs addObject:fa.notificationID];
|
|
[dbRefs addObject:fa.feed.notificationID];
|
|
}
|
|
}
|
|
|
|
[moc reset];
|
|
PostNotification(kNotificationTotalUnreadCountChanged, @(countChange));
|
|
return dbRefs;
|
|
}
|
|
|
|
|
|
#pragma mark - Restore Sound State
|
|
|
|
/// Remove orphan core data entries with optional alert message of removed items count.
|
|
+ (void)cleanupAndShowAlert:(BOOL)flag {
|
|
NSUInteger deleted = [self deleteUnreferenced];
|
|
[self restoreFeedIndexPaths];
|
|
PostNotification(kNotificationTotalUnreadCountReset, nil);
|
|
if (flag) {
|
|
NSAlert *alert = [[NSAlert alloc] init];
|
|
alert.messageText = NSLocalizedString(@"Database cleanup successful", nil);
|
|
alert.informativeText = [NSString stringWithFormat:NSLocalizedString(@"Removed %lu unreferenced database entries.", nil), deleted];
|
|
alert.alertStyle = NSAlertStyleInformational;
|
|
[alert runModal];
|
|
}
|
|
}
|
|
|
|
/// Iterate over all @c Feed and re-calculate @c indexPath.
|
|
+ (void)restoreFeedIndexPaths {
|
|
NSManagedObjectContext *moc = [self getMainContext];
|
|
for (Feed *f in [[Feed fetchRequest] fetchAllRows:moc]) {
|
|
[f calculateAndSetIndexPathString];
|
|
}
|
|
[self saveContext:moc andParent:YES];
|
|
[moc reset];
|
|
}
|
|
|
|
/**
|
|
Delete all @c Feed items where @c group @c = @c NULL and all @c FeedMeta, @c FeedIcon, @c FeedArticle where @c feed @c = @c NULL.
|
|
*/
|
|
+ (NSUInteger)deleteUnreferenced {
|
|
NSUInteger deleted = 0;
|
|
NSManagedObjectContext *moc = [self getMainContext];
|
|
deleted += [self batchDelete:Feed.entity nullAttribute:@"group" inContext:moc];
|
|
deleted += [self batchDelete:FeedMeta.entity nullAttribute:@"feed" inContext:moc];
|
|
deleted += [self batchDelete:FeedArticle.entity nullAttribute:@"feed" inContext:moc];
|
|
if (deleted > 0) {
|
|
[self saveContext:moc andParent:YES];
|
|
[moc reset];
|
|
}
|
|
return deleted;
|
|
}
|
|
|
|
/// Delete all @c FeedGroup items.
|
|
//+ (NSUInteger)deleteAllGroups {
|
|
// NSManagedObjectContext *moc = [self getMainContext];
|
|
// NSUInteger deleted = [self batchDelete:FeedGroup.entity nullAttribute:nil inContext:moc];
|
|
// [self saveContext:moc andParent:YES];
|
|
// [moc reset];
|
|
// return deleted;
|
|
//}
|
|
|
|
/**
|
|
Perform batch delete on entities of type @c entity where @c column @c IS @c NULL. If @c column is @c nil, delete all rows.
|
|
*/
|
|
+ (NSUInteger)batchDelete:(NSEntityDescription*)entity nullAttribute:(NSString*)column inContext:(NSManagedObjectContext*)moc {
|
|
NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName: entity.name];
|
|
if (column && column.length > 0) {
|
|
// using @count here to also find items where foreign key is set but referencing a non-existing object.
|
|
fr.predicate = [NSPredicate predicateWithFormat:@"count(%K) == 0", column];
|
|
}
|
|
NSBatchDeleteRequest *bdr = [[NSBatchDeleteRequest alloc] initWithFetchRequest:fr];
|
|
bdr.resultType = NSBatchDeleteResultTypeCount;
|
|
NSError *err;
|
|
NSBatchDeleteResult *res = [moc executeRequest:bdr error:&err];
|
|
[err inCaseLog:"Couldn't delete batch"];
|
|
return [res.result unsignedIntegerValue];
|
|
}
|
|
|
|
/// Remove orphan favicons. @return Number of removed items.
|
|
+ (NSUInteger)cleanupFavicons {
|
|
NSURL *base = [[NSURL faviconsCacheURL] URLByResolvingSymlinksInPath];
|
|
if (![base existsAndIsDir:YES]) return 0;
|
|
|
|
NSFileManager *fm = [NSFileManager defaultManager];
|
|
NSDirectoryEnumerationOptions opt = NSDirectoryEnumerationSkipsSubdirectoryDescendants | NSDirectoryEnumerationSkipsPackageDescendants | NSDirectoryEnumerationSkipsHiddenFiles;
|
|
NSDirectoryEnumerator *enumerator = [fm enumeratorAtURL:base includingPropertiesForKeys:nil options:opt errorHandler:nil];
|
|
NSMutableArray<NSURL*> *toBeDeleted = [NSMutableArray array];
|
|
|
|
NSArray<NSManagedObjectID*> *feedIds = [[Feed fetchRequest] fetchIDs:[self getMainContext]];
|
|
NSArray<NSString*> *pks = [feedIds valueForKeyPath:@"URIRepresentation.lastPathComponent"];
|
|
|
|
for (NSURL *path in enumerator)
|
|
if (![pks containsObject:path.lastPathComponent])
|
|
[toBeDeleted addObject:path];
|
|
for (NSURL *path in toBeDeleted)
|
|
[fm removeItemAtURL:path error:nil];
|
|
return toBeDeleted.count;
|
|
}
|
|
|
|
@end
|