Refactoring Status Menu
This commit is contained in:
40
baRSS/Core Data/Feed+Ext.h
Normal file
40
baRSS/Core Data/Feed+Ext.h
Normal file
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// 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 "Feed+CoreDataClass.h"
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
@class RSParsedFeed;
|
||||
|
||||
@interface Feed (Ext)
|
||||
// Generator methods / Feed update
|
||||
+ (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context;
|
||||
+ (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc;
|
||||
- (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag;
|
||||
- (void)calculateAndSetIndexPathString;
|
||||
- (NSMenuItem*)newMenuItem;
|
||||
// Article properties
|
||||
- (NSArray<FeedArticle*>*)sortedArticles;
|
||||
// Icon
|
||||
- (NSImage*)iconImage16;
|
||||
- (BOOL)setIconImage:(NSImage*)img;
|
||||
@end
|
||||
248
baRSS/Core Data/Feed+Ext.m
Normal file
248
baRSS/Core Data/Feed+Ext.m
Normal file
@@ -0,0 +1,248 @@
|
||||
//
|
||||
// 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 "Feed+Ext.h"
|
||||
#import "Constants.h"
|
||||
#import "UserPrefs.h"
|
||||
#import "DrawImage.h"
|
||||
#import "FeedMeta+Ext.h"
|
||||
#import "FeedGroup+Ext.h"
|
||||
#import "FeedArticle+Ext.h"
|
||||
#import "StoreCoordinator.h"
|
||||
|
||||
#import <RSXML/RSXML.h>
|
||||
|
||||
@implementation Feed (Ext)
|
||||
|
||||
/// Instantiates new @c Feed and @c FeedMeta entities in context.
|
||||
+ (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)moc {
|
||||
Feed *feed = [[Feed alloc] initWithEntity:Feed.entity insertIntoManagedObjectContext:moc];
|
||||
feed.meta = [[FeedMeta alloc] initWithEntity:FeedMeta.entity insertIntoManagedObjectContext:moc];
|
||||
return feed;
|
||||
}
|
||||
|
||||
/// Instantiates new @c FeedGroup with @c FEED type, set the update interval to @c 30min and @c sortIndex to last root index.
|
||||
+ (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc {
|
||||
NSUInteger lastIndex = [StoreCoordinator countRootItemsInContext:moc];
|
||||
FeedGroup *fg = [FeedGroup newGroup:FEED inContext:moc];
|
||||
[fg setParent:nil andSortIndex:(int32_t)lastIndex];
|
||||
[fg.feed.meta setRefreshAndSchedule:kDefaultFeedRefreshInterval];
|
||||
return fg.feed;
|
||||
}
|
||||
|
||||
/// Call @c indexPathString on @c .group and update @c .indexPath if current value is different.
|
||||
- (void)calculateAndSetIndexPathString {
|
||||
NSString *pthStr = [self.group indexPathString];
|
||||
if (![self.indexPath isEqualToString:pthStr])
|
||||
self.indexPath = pthStr;
|
||||
}
|
||||
|
||||
/// @return Fully initialized @c NSMenuItem with @c title, @c tooltip, @c image, and @c action.
|
||||
- (NSMenuItem*)newMenuItem {
|
||||
NSMenuItem *item = [NSMenuItem new];
|
||||
item.title = self.group.nameOrError;
|
||||
item.toolTip = self.subtitle;
|
||||
item.enabled = (self.articles.count > 0);
|
||||
item.image = [self iconImage16];
|
||||
item.representedObject = self.indexPath;
|
||||
item.target = [self class];
|
||||
item.action = @selector(didClickOnMenuItem:);
|
||||
return item;
|
||||
}
|
||||
|
||||
/// Callback method for @c NSMenuItem. Will open url associated with @c Feed.
|
||||
+ (void)didClickOnMenuItem:(NSMenuItem*)sender {
|
||||
NSString *url = [StoreCoordinator urlForFeedWithIndexPath:sender.representedObject];
|
||||
if (url && url.length > 0)
|
||||
[UserPrefs openURLsWithPreferredBrowser:@[[NSURL URLWithString:url]]];
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Update Feed Items -
|
||||
|
||||
|
||||
/**
|
||||
Replace feed title, subtitle and link (if changed). Also adds new articles and removes old ones.
|
||||
*/
|
||||
- (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag {
|
||||
if (![self.title isEqualToString:obj.title]) self.title = obj.title;
|
||||
if (![self.subtitle isEqualToString:obj.subtitle]) self.subtitle = obj.subtitle;
|
||||
if (![self.link isEqualToString:obj.link]) self.link = obj.link;
|
||||
|
||||
if (self.group.name.length == 0) // in case a blank group was initialized
|
||||
self.group.name = obj.title;
|
||||
|
||||
// Add and remove articles
|
||||
NSMutableSet<NSString*> *urls = [[self.articles valueForKeyPath:@"link"] mutableCopy];
|
||||
NSInteger diff = [self addMissingArticles:obj updateLinks:urls]; // will remove links in 'urls' that should be kept
|
||||
diff -= [self deleteArticlesWithLink:urls]; // remove old, outdated articles
|
||||
// Get new total article count and post unread-count-change notification
|
||||
if (flag && diff != 0) {
|
||||
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged object:@(diff)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Append new articles and increment their sortIndex. Update unread counter on the way.
|
||||
|
||||
@note
|
||||
New articles should be in ascending order without any gaps in between.
|
||||
If new article is disjunct from the article before, assume a deleted article re-appeared and mark it as read.
|
||||
|
||||
@param urls Input will be used to identify new articles. Output will contain URLs that aren't present in the feed anymore.
|
||||
*/
|
||||
- (NSInteger)addMissingArticles:(RSParsedFeed*)obj updateLinks:(NSMutableSet<NSString*>*)urls {
|
||||
NSInteger newOnes = 0;
|
||||
int32_t currentIndex = [[self.articles valueForKeyPath:@"@min.sortIndex"] intValue];
|
||||
FeedArticle *lastInserted = nil;
|
||||
BOOL hasGapBetweenNewArticles = NO;
|
||||
|
||||
for (RSParsedArticle *article in [obj.articles reverseObjectEnumerator]) {
|
||||
// reverse enumeration ensures correct article order
|
||||
if ([urls containsObject:article.link]) {
|
||||
[urls removeObject:article.link];
|
||||
FeedArticle *storedArticle = [self findArticleWithLink:article.link]; // TODO: use two synced arrays?
|
||||
if (storedArticle && storedArticle.sortIndex != currentIndex) {
|
||||
storedArticle.sortIndex = currentIndex;
|
||||
}
|
||||
hasGapBetweenNewArticles = YES;
|
||||
} else {
|
||||
newOnes += 1;
|
||||
if (hasGapBetweenNewArticles && lastInserted) { // gap with at least one article inbetween
|
||||
lastInserted.unread = NO;
|
||||
newOnes -= 1;
|
||||
}
|
||||
hasGapBetweenNewArticles = NO;
|
||||
lastInserted = [FeedArticle newArticle:article inContext:self.managedObjectContext];
|
||||
lastInserted.sortIndex = currentIndex;
|
||||
[self addArticlesObject:lastInserted];
|
||||
}
|
||||
currentIndex += 1;
|
||||
}
|
||||
if (hasGapBetweenNewArticles && lastInserted) {
|
||||
lastInserted.unread = NO;
|
||||
newOnes -= 1;
|
||||
}
|
||||
return newOnes;
|
||||
}
|
||||
|
||||
/**
|
||||
Delete all items where @c link matches one of the URLs in the @c NSSet.
|
||||
*/
|
||||
- (NSUInteger)deleteArticlesWithLink:(NSMutableSet<NSString*>*)urls {
|
||||
if (!urls || urls.count == 0)
|
||||
return 0;
|
||||
NSUInteger c = 0;
|
||||
for (FeedArticle *fa in self.articles) {
|
||||
if ([urls containsObject:fa.link]) {
|
||||
[urls removeObject:fa.link];
|
||||
if (fa.unread) ++c;
|
||||
// TODO: keep unread articles?
|
||||
[self.managedObjectContext deleteObject:fa];
|
||||
if (urls.count == 0)
|
||||
break;
|
||||
}
|
||||
}
|
||||
NSSet<FeedArticle*> *delArticles = [self.managedObjectContext deletedObjects];
|
||||
if (delArticles.count > 0) {
|
||||
[self removeArticles:delArticles];
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Article Properties -
|
||||
|
||||
|
||||
/**
|
||||
@return Articles sorted by attribute @c sortIndex with descending order (newest items first).
|
||||
*/
|
||||
- (NSArray<FeedArticle*>*)sortedArticles {
|
||||
if (self.articles.count == 0)
|
||||
return nil;
|
||||
return [self.articles sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:NO]]];
|
||||
}
|
||||
|
||||
/**
|
||||
Iterate over all Articles and return the one where @c .link matches. Or @c nil if no matching article found.
|
||||
*/
|
||||
- (FeedArticle*)findArticleWithLink:(NSString*)url {
|
||||
for (FeedArticle *a in self.articles) {
|
||||
if ([a.link isEqualToString:url])
|
||||
return a;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Icon -
|
||||
|
||||
|
||||
/**
|
||||
@return Return @c 16x16px image. Either from core data storage or generated default RSS icon.
|
||||
*/
|
||||
- (NSImage*)iconImage16 {
|
||||
NSData *imgData = self.icon.icon;
|
||||
if (imgData)
|
||||
{
|
||||
NSImage *img = [[NSImage alloc] initWithData:imgData];
|
||||
[img setSize:NSMakeSize(16, 16)];
|
||||
return img;
|
||||
}
|
||||
else if (self.articles.count == 0)
|
||||
{
|
||||
static NSImage *warningIcon;
|
||||
if (!warningIcon) {
|
||||
warningIcon = [NSImage imageNamed:NSImageNameCaution];
|
||||
[warningIcon setSize:NSMakeSize(16, 16)];
|
||||
}
|
||||
return warningIcon;
|
||||
}
|
||||
else
|
||||
{
|
||||
static NSImage *defaultRSSIcon; // TODO: setup imageNamed: for default rss icon
|
||||
if (!defaultRSSIcon)
|
||||
defaultRSSIcon = [RSSIcon iconWithSize:16];
|
||||
return defaultRSSIcon;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Set favicon icon or delete relationship if @c img is not a valid image.
|
||||
|
||||
@return @c YES if icon was updated (core data did change).
|
||||
*/
|
||||
- (BOOL)setIconImage:(NSImage*)img {
|
||||
if (img && [img isValid]) {
|
||||
if (!self.icon)
|
||||
self.icon = [[FeedIcon alloc] initWithEntity:FeedIcon.entity insertIntoManagedObjectContext:self.managedObjectContext];
|
||||
self.icon.icon = [img TIFFRepresentation];
|
||||
return YES;
|
||||
} else if (self.icon) {
|
||||
[self.managedObjectContext deleteObject:self.icon];
|
||||
self.icon = nil;
|
||||
return YES;
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
@end
|
||||
31
baRSS/Core Data/FeedArticle+Ext.h
Normal file
31
baRSS/Core Data/FeedArticle+Ext.h
Normal file
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// The MIT License (MIT)
|
||||
// Copyright (c) 2019 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 "FeedArticle+CoreDataClass.h"
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
@class RSParsedArticle;
|
||||
|
||||
@interface FeedArticle (Ext)
|
||||
+ (instancetype)newArticle:(RSParsedArticle*)entry inContext:(NSManagedObjectContext*)moc;
|
||||
- (NSMenuItem*)newMenuItem;
|
||||
@end
|
||||
94
baRSS/Core Data/FeedArticle+Ext.m
Normal file
94
baRSS/Core Data/FeedArticle+Ext.m
Normal file
@@ -0,0 +1,94 @@
|
||||
//
|
||||
// The MIT License (MIT)
|
||||
// Copyright (c) 2019 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 "FeedArticle+Ext.h"
|
||||
#import "Constants.h"
|
||||
#import "UserPrefs.h"
|
||||
#import "StoreCoordinator.h"
|
||||
|
||||
#import <RSXML/RSParsedArticle.h>
|
||||
|
||||
@implementation FeedArticle (Ext)
|
||||
|
||||
/// Create new article based on RSXML article input.
|
||||
+ (instancetype)newArticle:(RSParsedArticle*)entry inContext:(NSManagedObjectContext*)moc {
|
||||
FeedArticle *fa = [[FeedArticle alloc] initWithEntity:FeedArticle.entity insertIntoManagedObjectContext:moc];
|
||||
fa.unread = YES;
|
||||
fa.guid = entry.guid;
|
||||
fa.title = entry.title;
|
||||
fa.abstract = entry.abstract;
|
||||
fa.body = entry.body;
|
||||
fa.author = entry.author;
|
||||
fa.link = entry.link;
|
||||
fa.published = entry.datePublished;
|
||||
if (!fa.published)
|
||||
fa.published = entry.dateModified;
|
||||
return fa;
|
||||
}
|
||||
|
||||
/// @return Full or truncated article title, based on user preference in settings.
|
||||
- (NSString*)shortArticleName {
|
||||
NSString *title = self.title;
|
||||
if (!title) return @"";
|
||||
// TODO: It should be enough to get user prefs once per menu build
|
||||
if ([UserPrefs defaultNO:@"feedShortNames"]) {
|
||||
NSUInteger limit = [UserPrefs shortArticleNamesLimit];
|
||||
if (title.length > limit)
|
||||
title = [NSString stringWithFormat:@"%@…", [title substringToIndex:limit-1]];
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
/// @return Fully initialized @c NSMenuItem with @c title, @c tooltip, @c tickmark, and @c action.
|
||||
- (NSMenuItem*)newMenuItem {
|
||||
NSMenuItem *item = [NSMenuItem new];
|
||||
item.title = [self shortArticleName];
|
||||
item.enabled = (self.link.length > 0);
|
||||
item.state = (self.unread && [UserPrefs defaultYES:@"feedTickMark"] ? NSControlStateValueOn : NSControlStateValueOff);
|
||||
//mi.toolTip = item.abstract;
|
||||
// TODO: Do regex during save, not during display. Its here for testing purposes ...
|
||||
if (self.abstract.length > 0) {
|
||||
NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"<[^>]*>" options:kNilOptions error:nil];
|
||||
item.toolTip = [regex stringByReplacingMatchesInString:self.abstract options:kNilOptions range:NSMakeRange(0, self.abstract.length) withTemplate:@""];
|
||||
}
|
||||
item.representedObject = self.objectID;
|
||||
item.target = [self class];
|
||||
item.action = @selector(didClickOnMenuItem:);
|
||||
return item;
|
||||
}
|
||||
|
||||
/// Callback method for @c NSMenuItem. Will open url associated with @c FeedArticle and mark it read.
|
||||
+ (void)didClickOnMenuItem:(NSMenuItem*)sender {
|
||||
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
||||
FeedArticle *fa = [moc objectWithID:sender.representedObject];
|
||||
NSString *url = fa.link;
|
||||
if (fa.unread) {
|
||||
fa.unread = NO;
|
||||
[StoreCoordinator saveContext:moc andParent:YES];
|
||||
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged object:@-1];
|
||||
}
|
||||
[moc reset];
|
||||
if (url && url.length > 0)
|
||||
[UserPrefs openURLsWithPreferredBrowser:@[[NSURL URLWithString:url]]];
|
||||
}
|
||||
|
||||
@end
|
||||
51
baRSS/Core Data/FeedGroup+Ext.h
Normal file
51
baRSS/Core Data/FeedGroup+Ext.h
Normal file
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// 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 "FeedGroup+CoreDataClass.h"
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
/// Enum type to distinguish different @c FeedGroup types: @c GROUP, @c FEED, @c SEPARATOR
|
||||
typedef NS_ENUM(int16_t, FeedGroupType) {
|
||||
/// Other types: @c GROUP, @c FEED, @c SEPARATOR
|
||||
GROUP = 0, FEED = 1, SEPARATOR = 2
|
||||
};
|
||||
|
||||
|
||||
@interface FeedGroup (Ext)
|
||||
/// Overwrites @c type attribute with enum. Use one of: @c GROUP, @c FEED, @c SEPARATOR.
|
||||
@property (nonatomic) FeedGroupType type;
|
||||
@property (nonnull, readonly) NSString *nameOrError;
|
||||
|
||||
+ (instancetype)newGroup:(FeedGroupType)type inContext:(NSManagedObjectContext*)context;
|
||||
- (void)setParent:(FeedGroup *)parent andSortIndex:(int32_t)sortIndex;
|
||||
- (void)setNameIfChanged:(NSString*)name;
|
||||
- (NSImage*)groupIconImage16;
|
||||
- (NSMenuItem*)newMenuItem;
|
||||
// Handle children and parents
|
||||
- (NSString*)indexPathString;
|
||||
- (NSArray<FeedGroup*>*)sortedChildren;
|
||||
- (NSMutableArray<FeedGroup*>*)allParents;
|
||||
- (BOOL)iterateSorted:(BOOL)ordered overDescendantFeeds:(void(^)(Feed *feed, BOOL* cancel))block;
|
||||
// Printing
|
||||
- (NSString*)readableDescription;
|
||||
- (nonnull NSString*)refreshString;
|
||||
@end
|
||||
150
baRSS/Core Data/FeedGroup+Ext.m
Normal file
150
baRSS/Core Data/FeedGroup+Ext.m
Normal file
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// 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 "FeedGroup+Ext.h"
|
||||
#import "FeedMeta+Ext.h"
|
||||
#import "Feed+Ext.h"
|
||||
#import "NSDate+Ext.h"
|
||||
|
||||
@implementation FeedGroup (Ext)
|
||||
|
||||
/// Create new instance and set @c Feed and @c FeedMeta if group type is @c FEED
|
||||
+ (instancetype)newGroup:(FeedGroupType)type inContext:(NSManagedObjectContext*)moc {
|
||||
FeedGroup *fg = [[FeedGroup alloc] initWithEntity: FeedGroup.entity insertIntoManagedObjectContext:moc];
|
||||
fg.type = type;
|
||||
if (type == FEED)
|
||||
fg.feed = [Feed newFeedAndMetaInContext:moc];
|
||||
return fg;
|
||||
}
|
||||
|
||||
- (void)setParent:(FeedGroup *)parent andSortIndex:(int32_t)sortIndex {
|
||||
self.parent = parent;
|
||||
self.sortIndex = sortIndex;
|
||||
if (self.type == FEED)
|
||||
[self.feed calculateAndSetIndexPathString];
|
||||
}
|
||||
|
||||
/// Set @c name attribute but only if value differs.
|
||||
- (void)setNameIfChanged:(NSString*)name {
|
||||
if (![self.name isEqualToString: name])
|
||||
self.name = name;
|
||||
}
|
||||
|
||||
/// @return Return static @c 16x16px NSImageNameFolder image.
|
||||
- (NSImage*)groupIconImage16 {
|
||||
static NSImage *groupIcon;
|
||||
if (!groupIcon) {
|
||||
groupIcon = [NSImage imageNamed:NSImageNameFolder];
|
||||
groupIcon.size = NSMakeSize(16, 16);
|
||||
}
|
||||
return groupIcon;
|
||||
}
|
||||
|
||||
/// @return Returns "(error)" if @c self.name is @c nil.
|
||||
- (nonnull NSString*)nameOrError {
|
||||
return (self.name ? self.name : NSLocalizedString(@"(error)", nil));
|
||||
}
|
||||
|
||||
/// @return Fully initialized @c NSMenuItem with @c title and @c image.
|
||||
- (NSMenuItem*)newMenuItem {
|
||||
NSMenuItem *item = [NSMenuItem new];
|
||||
item.title = self.nameOrError;
|
||||
item.enabled = (self.children.count > 0);
|
||||
item.image = [self groupIconImage16];
|
||||
item.representedObject = self.objectID;
|
||||
return item;
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Handle Children And Parents -
|
||||
|
||||
|
||||
/// @return IndexPath as semicolon separated string for sorted children starting with root index.
|
||||
- (NSString*)indexPathString {
|
||||
if (self.parent == nil)
|
||||
return [NSString stringWithFormat:@"%d", self.sortIndex];
|
||||
return [[self.parent indexPathString] stringByAppendingFormat:@".%d", self.sortIndex];
|
||||
}
|
||||
|
||||
/// @return Children sorted by attribute @c sortIndex (same order as in preferences).
|
||||
- (NSArray<FeedGroup*>*)sortedChildren {
|
||||
if (self.children.count == 0)
|
||||
return nil;
|
||||
return [self.children sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]]];
|
||||
}
|
||||
|
||||
/// @return @c NSArray of all ancestors: First object is root. Last object is the @c FeedGroup that executed the command.
|
||||
- (NSMutableArray<FeedGroup*>*)allParents {
|
||||
if (self.parent == nil)
|
||||
return [NSMutableArray arrayWithObject:self];
|
||||
NSMutableArray *arr = [self.parent allParents];
|
||||
[arr addObject:self];
|
||||
return arr;
|
||||
}
|
||||
|
||||
/**
|
||||
Iterate over all descenden feeds.
|
||||
|
||||
@param ordered If @c YES items are executed in the same order they are listed in the menu. Pass @n NO for a speed-up.
|
||||
@param block Set @c cancel to @c YES to stop execution of further descendants.
|
||||
@return @c NO if execution was stopped with @c cancel @c = @c YES in @c block.
|
||||
*/
|
||||
- (BOOL)iterateSorted:(BOOL)ordered overDescendantFeeds:(void(^)(Feed*,BOOL*))block {
|
||||
if (self.feed) {
|
||||
BOOL stopEarly = NO;
|
||||
block(self.feed, &stopEarly);
|
||||
if (stopEarly) return NO;
|
||||
} else {
|
||||
for (FeedGroup *fg in (ordered ? [self sortedChildren] : self.children)) {
|
||||
if (![fg iterateSorted:ordered overDescendantFeeds:block])
|
||||
return NO;
|
||||
}
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Printing -
|
||||
|
||||
|
||||
/// @return Simplified description of the feed object.
|
||||
- (NSString*)readableDescription {
|
||||
switch (self.type) {
|
||||
case SEPARATOR: return @"-------------";
|
||||
case GROUP: return [NSString stringWithFormat:@"%@", self.name];
|
||||
case FEED:
|
||||
return [NSString stringWithFormat:@"%@ (%@) - %@", self.name, self.feed.meta.url, [self refreshString]];
|
||||
}
|
||||
}
|
||||
|
||||
/// @return Formatted string for update interval ( e.g., @c 30m or @c 12h )
|
||||
- (nonnull NSString*)refreshString {
|
||||
if (self.type == FEED) {
|
||||
int32_t refresh = self.feed.meta.refresh;
|
||||
if (refresh <= 0)
|
||||
return @"∞"; // ∞ ƒ Ø
|
||||
return [NSDate stringForInterval:refresh rounded:NO];
|
||||
}
|
||||
return @"";
|
||||
}
|
||||
|
||||
@end
|
||||
35
baRSS/Core Data/FeedMeta+Ext.h
Normal file
35
baRSS/Core Data/FeedMeta+Ext.h
Normal file
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// 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 "FeedMeta+CoreDataClass.h"
|
||||
|
||||
static const int32_t kDefaultFeedRefreshInterval = 30 * 60;
|
||||
|
||||
@interface FeedMeta (Ext)
|
||||
// HTTP response
|
||||
- (void)setErrorAndPostponeSchedule;
|
||||
- (void)setSucessfulWithResponse:(NSHTTPURLResponse*)response;
|
||||
// Setter
|
||||
- (void)setUrlIfChanged:(NSString*)url;
|
||||
- (void)setEtag:(NSString*)etag modified:(NSString*)modified;
|
||||
- (BOOL)setRefreshAndSchedule:(int32_t)refresh;
|
||||
@end
|
||||
89
baRSS/Core Data/FeedMeta+Ext.m
Normal file
89
baRSS/Core Data/FeedMeta+Ext.m
Normal file
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// 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 "FeedMeta+Ext.h"
|
||||
#import "Feed+Ext.h"
|
||||
#import "FeedGroup+Ext.h"
|
||||
|
||||
@implementation FeedMeta (Ext)
|
||||
|
||||
#pragma mark - HTTP response
|
||||
|
||||
/// Increment @c errorCount and set new @c scheduled date (2^N minutes, max. 5.7 days).
|
||||
- (void)setErrorAndPostponeSchedule {
|
||||
if (self.errorCount < 0)
|
||||
self.errorCount = 0;
|
||||
int16_t n = self.errorCount + 1; // always increment errorCount (can be used to indicate bad feeds)
|
||||
// TODO: remove logging
|
||||
NSLog(@"ERROR: Feed download failed: %@ (errorCount: %d)", self.url, n);
|
||||
if ([self.scheduled timeIntervalSinceNow] > 30) // forced, early update. Scheduled is still in the futute.
|
||||
return; // Keep error counter low. Not enough time has passed (e.g., temporary server outage)
|
||||
NSTimeInterval retryWaitTime = pow(2, (n > 13 ? 13 : n)) * 60; // 2^N (between: 2 minutes and 5.7 days)
|
||||
self.errorCount = n;
|
||||
[self scheduleNow:retryWaitTime];
|
||||
}
|
||||
|
||||
- (void)setSucessfulWithResponse:(NSHTTPURLResponse*)response {
|
||||
self.errorCount = 0; // reset counter
|
||||
NSDictionary *header = [response allHeaderFields];
|
||||
[self setEtag:header[@"Etag"] modified:header[@"Date"]]; // @"Expires", @"Last-Modified"
|
||||
[self scheduleNow:self.refresh];
|
||||
}
|
||||
|
||||
#pragma mark - Setter
|
||||
|
||||
/// Set @c url attribute but only if value differs.
|
||||
- (void)setUrlIfChanged:(NSString*)url {
|
||||
if (![self.url isEqualToString:url]) self.url = url;
|
||||
}
|
||||
|
||||
/// Set @c etag and @c modified attributes. Only values that differ will be updated.
|
||||
- (void)setEtag:(NSString*)etag modified:(NSString*)modified {
|
||||
if (![self.etag isEqualToString:etag]) self.etag = etag;
|
||||
if (![self.modified isEqualToString:modified]) self.modified = modified;
|
||||
}
|
||||
|
||||
/**
|
||||
Set @c refresh and calculate new @c scheduled date.
|
||||
|
||||
@return @c YES if refresh interval has changed
|
||||
*/
|
||||
- (BOOL)setRefreshAndSchedule:(int32_t)refresh {
|
||||
if (self.refresh != refresh) {
|
||||
self.refresh = refresh;
|
||||
[self scheduleNow:self.refresh];
|
||||
return YES;
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
/// Set next scheduled feed update or @c nil if @c refresh @c <= @c 0.
|
||||
- (void)scheduleNow:(NSTimeInterval)future {
|
||||
if (self.refresh <= 0) { // update deactivated; manually update with force update all
|
||||
if (self.scheduled != nil) // already nil? Avoid unnecessary core data edits
|
||||
self.scheduled = nil;
|
||||
} else {
|
||||
self.scheduled = [NSDate dateWithTimeIntervalSinceNow:future];
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
38
baRSS/Core Data/NSFetchRequest+Ext.h
Normal file
38
baRSS/Core Data/NSFetchRequest+Ext.h
Normal file
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// The MIT License (MIT)
|
||||
// Copyright (c) 2019 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 <CoreData/CoreData.h>
|
||||
|
||||
@interface NSFetchRequest<ResultType> (Ext)
|
||||
// Perform core data request and fetch data
|
||||
- (NSArray<ResultType>*)fetchAllRows:(NSManagedObjectContext*)moc;
|
||||
- (NSArray<NSManagedObjectID*>*)fetchIDs:(NSManagedObjectContext*)moc;
|
||||
- (NSUInteger)fetchCount:(NSManagedObjectContext*)moc;
|
||||
- (id)fetchFirst:(NSManagedObjectContext*)moc; // limit 1
|
||||
|
||||
// Selecting, filtering, sorting results
|
||||
- (instancetype)select:(NSArray<NSString*>*)cols; // sets .propertiesToFetch
|
||||
- (instancetype)where:(NSString*)format, ...; // sets .predicate
|
||||
- (instancetype)sortASC:(NSString*)key; // add .sortDescriptors -> ascending:YES
|
||||
- (instancetype)sortDESC:(NSString*)key; // add .sortDescriptors -> ascending:NO
|
||||
- (instancetype)addFunctionExpression:(NSString*)fn onKeyPath:(NSString*)keyPath name:(NSString*)name type:(NSAttributeType)type; // add .propertiesToFetch -> (expressionForFunction:@[expressionForKeyPath:])
|
||||
@end
|
||||
133
baRSS/Core Data/NSFetchRequest+Ext.m
Normal file
133
baRSS/Core Data/NSFetchRequest+Ext.m
Normal file
@@ -0,0 +1,133 @@
|
||||
//
|
||||
// The MIT License (MIT)
|
||||
// Copyright (c) 2019 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 "NSFetchRequest+Ext.h"
|
||||
|
||||
@implementation NSFetchRequest (Ext)
|
||||
|
||||
/// Perform fetch and return result. If an error occurs, print it to the console.
|
||||
- (NSArray*)fetchAllRows:(NSManagedObjectContext*)moc {
|
||||
NSError *err;
|
||||
NSArray *fetchResults = [moc executeFetchRequest:self error:&err];
|
||||
if (err) NSLog(@"ERROR: Fetch request failed: %@", err);
|
||||
//NSLog(@"%@ ==> %@", self, fetchResults); // debugging
|
||||
return fetchResults;
|
||||
}
|
||||
|
||||
/// Set @c resultType to @c NSManagedObjectIDResultType and return list of object ids.
|
||||
- (NSArray<NSManagedObjectID*>*)fetchIDs:(NSManagedObjectContext*)moc {
|
||||
self.includesPropertyValues = NO;
|
||||
self.resultType = NSManagedObjectIDResultType;
|
||||
return [self fetchAllRows:moc];
|
||||
}
|
||||
|
||||
/// Set @c limit to @c 1 and fetch first objcect. May return object type or @c NSDictionary if @c resultType @c = @c NSManagedObjectIDResultType.
|
||||
- (id)fetchFirst:(NSManagedObjectContext*)moc {
|
||||
self.fetchLimit = 1;
|
||||
return [[self fetchAllRows:moc] firstObject];
|
||||
}
|
||||
|
||||
/// Convenient method to return the number of rows that match the request.
|
||||
- (NSUInteger)fetchCount:(NSManagedObjectContext*)moc {
|
||||
return [moc countForFetchRequest:self error:nil];
|
||||
}
|
||||
|
||||
#pragma mark - Selecting, Filtering, Sorting
|
||||
|
||||
/**
|
||||
Set @c self.propertiesToFetch = @c cols and @c self.resultType = @c NSDictionaryResultType.
|
||||
@return @c self (e.g., method chaining)
|
||||
*/
|
||||
- (instancetype)select:(NSArray<NSString*>*)cols {
|
||||
self.propertiesToFetch = cols;
|
||||
self.resultType = NSDictionaryResultType;
|
||||
return self;
|
||||
}
|
||||
|
||||
/**
|
||||
Set @c self.predicate = [NSPredicate predicateWithFormat: @c format ]
|
||||
@return @c self (e.g., method chaining)
|
||||
*/
|
||||
- (instancetype)where:(NSString*)format, ... {
|
||||
va_list arguments;
|
||||
va_start(arguments, format);
|
||||
self.predicate = [NSPredicate predicateWithFormat:format arguments:arguments];
|
||||
va_end(arguments);
|
||||
return self;
|
||||
}
|
||||
|
||||
/**
|
||||
Add new [NSSortDescriptor sortDescriptorWithKey: @c key ascending:YES] to @c self.sortDescriptors.
|
||||
@return @c self (e.g., method chaining)
|
||||
*/
|
||||
- (instancetype)sortASC:(NSString*)key {
|
||||
[self addSortingKey:key asc:YES];
|
||||
return self;
|
||||
}
|
||||
|
||||
/**
|
||||
Add new [NSSortDescriptor sortDescriptorWithKey: @c key ascending:NO] to @c self.sortDescriptors.
|
||||
@return @c self (e.g., method chaining)
|
||||
*/
|
||||
- (instancetype)sortDESC:(NSString*)key {
|
||||
[self addSortingKey:key asc:NO];
|
||||
return self;
|
||||
}
|
||||
|
||||
/**
|
||||
Add new [NSExpression expressionForFunction: @c fn arguments: [NSExpression expressionForKeyPath: @c keyPath ]] to @c self.propertiesToFetch.
|
||||
Also set @c self.includesPropertyValues @c = @c NO and @c self.resultType @c = @c NSDictionaryResultType.
|
||||
@return @c self (e.g., method chaining)
|
||||
*/
|
||||
- (instancetype)addFunctionExpression:(NSString*)fn onKeyPath:(NSString*)keyPath name:(NSString*)name type:(NSAttributeType)type {
|
||||
[self addExpression:[NSExpression expressionForFunction:fn arguments:@[[NSExpression expressionForKeyPath:keyPath]]] name:name type:type];
|
||||
return self;
|
||||
}
|
||||
|
||||
#pragma mark - Helper
|
||||
|
||||
/// Add @c NSSortDescriptor to existing list of @c sortDescriptors.
|
||||
- (void)addSortingKey:(NSString*)key asc:(BOOL)flag {
|
||||
NSSortDescriptor *sd = [NSSortDescriptor sortDescriptorWithKey:key ascending:flag];
|
||||
if (!self.sortDescriptors) {
|
||||
self.sortDescriptors = @[ sd ];
|
||||
} else {
|
||||
self.sortDescriptors = [self.sortDescriptors arrayByAddingObject:sd];
|
||||
}
|
||||
}
|
||||
|
||||
/// Add @c NSExpressionDescription to existing list of @c propertiesToFetch.
|
||||
- (void)addExpression:(NSExpression*)exp name:(NSString*)name type:(NSAttributeType)type {
|
||||
self.includesPropertyValues = NO;
|
||||
self.resultType = NSDictionaryResultType;
|
||||
NSExpressionDescription *expDesc = [[NSExpressionDescription alloc] init];
|
||||
[expDesc setName:name];
|
||||
[expDesc setExpression:exp];
|
||||
[expDesc setExpressionResultType:type];
|
||||
if (!self.propertiesToFetch) {
|
||||
self.propertiesToFetch = @[ expDesc ];
|
||||
} else {
|
||||
self.propertiesToFetch = [self.propertiesToFetch arrayByAddingObject:expDesc];
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
55
baRSS/Core Data/StoreCoordinator.h
Normal file
55
baRSS/Core Data/StoreCoordinator.h
Normal file
@@ -0,0 +1,55 @@
|
||||
//
|
||||
// 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 <Foundation/Foundation.h>
|
||||
#import "DBv1+CoreDataModel.h"
|
||||
|
||||
@interface StoreCoordinator : NSObject
|
||||
// Managing contexts
|
||||
+ (NSManagedObjectContext*)createChildContext;
|
||||
+ (void)saveContext:(NSManagedObjectContext*)context andParent:(BOOL)flag;
|
||||
|
||||
// Feed update
|
||||
+ (NSDate*)nextScheduledUpdate;
|
||||
+ (NSArray<Feed*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc;
|
||||
|
||||
// Count elements
|
||||
+ (NSUInteger)countTotalUnread;
|
||||
+ (NSUInteger)countRootItemsInContext:(NSManagedObjectContext*)moc;
|
||||
+ (NSArray<NSDictionary*>*)countAggregatedUnread;
|
||||
|
||||
// Get List Of Elements
|
||||
+ (NSArray<FeedGroup*>*)sortedFeedGroupsWithParent:(id)parent inContext:(NSManagedObjectContext*)moc;
|
||||
+ (NSArray<FeedArticle*>*)sortedArticlesWithParent:(id)parent inContext:(NSManagedObjectContext*)moc;
|
||||
+ (NSArray<Feed*>*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc;
|
||||
+ (NSArray<Feed*>*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc;
|
||||
+ (Feed*)feedWithIndexPath:(nonnull NSString*)path inContext:(NSManagedObjectContext*)moc;
|
||||
+ (NSString*)urlForFeedWithIndexPath:(nonnull NSString*)path;
|
||||
|
||||
// Unread articles list & mark articled read
|
||||
+ (NSArray<FeedArticle*>*)articlesAtPath:(nullable NSString*)path isFeed:(BOOL)feedFlag sorted:(BOOL)sortFlag unread:(BOOL)readFlag inContext:(NSManagedObjectContext*)moc limit:(NSUInteger)limit;
|
||||
|
||||
// Restore sound state
|
||||
+ (void)restoreFeedIndexPaths;
|
||||
+ (NSUInteger)deleteUnreferenced;
|
||||
+ (NSUInteger)deleteAllGroups;
|
||||
@end
|
||||
253
baRSS/Core Data/StoreCoordinator.m
Normal file
253
baRSS/Core Data/StoreCoordinator.m
Normal file
@@ -0,0 +1,253 @@
|
||||
//
|
||||
// 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 "StoreCoordinator.h"
|
||||
#import "NSFetchRequest+Ext.h"
|
||||
#import "AppHook.h"
|
||||
#import "Feed+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 {
|
||||
// Performs the save action for the application, which is to send the save: message to the application's managed object context. Any encountered errors are presented to the user.
|
||||
if (![context commitEditing]) {
|
||||
NSLog(@"%@:%@ unable to commit editing before saving", [self class], NSStringFromSelector(_cmd));
|
||||
}
|
||||
NSError *error = nil;
|
||||
if (context.hasChanges && ![context save:&error]) {
|
||||
// Customize this code block to include application-specific recovery steps.
|
||||
[[NSApplication sharedApplication] presentError:error];
|
||||
}
|
||||
if (flag && context.parentContext) {
|
||||
[self saveContext:context.parentContext andParent:flag];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#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 fetchAllRows: [self getMainContext]].firstObject[@"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.
|
||||
*/
|
||||
+ (NSArray<Feed*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc {
|
||||
NSFetchRequest *fr = [Feed fetchRequest];
|
||||
if (!forceAll) {
|
||||
// when fetching also get those feeds that would need update soon (now + 10s)
|
||||
[fr where:@"meta.scheduled <= %@", [NSDate dateWithTimeIntervalSinceNow:+10]];
|
||||
}
|
||||
return [fr fetchAllRows:moc];
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Count Elements
|
||||
|
||||
/// @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 [fr fetchAllRows: [self getMainContext]];
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Get List Of Elements
|
||||
|
||||
/// @return Sorted list of @c FeedGroup items where @c FeedGroup.parent @c = @c parent.
|
||||
+ (NSArray<FeedGroup*>*)sortedFeedGroupsWithParent:(id)parent inContext:(NSManagedObjectContext*)moc {
|
||||
return [[[[FeedGroup fetchRequest] where:@"parent = %@", parent] sortASC:@"sortIndex"] fetchAllRows:moc];
|
||||
}
|
||||
|
||||
/// @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];
|
||||
}
|
||||
|
||||
/// @return Unsorted list of @c Feed items where @c icon is @c nil.
|
||||
+ (NSArray<Feed*>*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc {
|
||||
return [[[Feed fetchRequest] where:@"icon = NULL"] 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];
|
||||
}
|
||||
|
||||
/// @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"]] fetchFirst: [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];
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Restore Sound State
|
||||
|
||||
/// 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:FeedIcon.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];
|
||||
if (err) NSLog(@"%@", err);
|
||||
return [res.result unsignedIntegerValue];
|
||||
}
|
||||
|
||||
@end
|
||||
Reference in New Issue
Block a user