Refactoring Status Menu

This commit is contained in:
relikd
2019-02-10 19:39:51 +01:00
parent cd0a1a3fd7
commit f2cca57fbb
39 changed files with 1491 additions and 1254 deletions

View 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
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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