mirror of
https://github.com/ViennaRSS/vienna-rss.git
synced 2026-04-07 19:27:39 +00:00
2530 lines
91 KiB
Objective-C
2530 lines
91 KiB
Objective-C
//
|
|
// Database.m
|
|
// Vienna
|
|
//
|
|
// Created by Steve on Tue Feb 03 2004.
|
|
// Copyright (c) 2004-2017 Steve Palmer and Vienna contributors. All rights reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//
|
|
|
|
#import "Database.h"
|
|
|
|
@import os.log;
|
|
|
|
#import "Database+Migration.h"
|
|
#import "Preferences.h"
|
|
#import "StringExtensions.h"
|
|
#import "Constants.h"
|
|
#import "ArticleRef.h"
|
|
#import "NSNotificationAdditions.h"
|
|
#import "Article.h"
|
|
#import "Folder.h"
|
|
#import "Field.h"
|
|
#import "Vienna-Swift.h"
|
|
|
|
#define VNA_LOG os_log_create("--", "Database")
|
|
|
|
@interface Database ()
|
|
|
|
@property (nonatomic) BOOL initializedfoldersDict;
|
|
@property (nonatomic) BOOL initializedSmartfoldersDict;
|
|
@property (nonatomic) NSMutableArray *fieldsOrdered;
|
|
@property (nonatomic) NSMutableDictionary *fieldsByName;
|
|
@property (nonatomic) NSMutableDictionary *foldersDict;
|
|
@property (nonatomic) NSMutableDictionary *smartfoldersDict;
|
|
@property (readwrite, nonatomic) BOOL readOnly;
|
|
@property (readwrite, nonatomic) NSInteger countOfUnread;
|
|
|
|
- (void)initaliseFields;
|
|
- (NSString *)relocateLockedDatabase:(NSString *)path;
|
|
- (CriteriaTree *)criteriaForFolder:(NSInteger)folderId;
|
|
- (NSArray *)arrayOfSubFolders:(Folder *)folder;
|
|
- (void)createInitialSmartFolder:(NSString *)folderName withCriteria:(Criteria *)criteria;
|
|
- (NSInteger)createFolderOnDatabase:(NSString *)name underParent:(NSInteger)parentId withType:(NSInteger)type;
|
|
+ (NSString *)databasePath;
|
|
|
|
@end
|
|
|
|
// The current database version number
|
|
static NSInteger const VNAMinimumSupportedDatabaseVersion = 12;
|
|
static NSInteger const VNACurrentDatabaseVersion = 23;
|
|
|
|
@implementation Database
|
|
|
|
NSNotificationName const VNADatabaseWillDeleteFolderNotification = @"Database Will Delete Folder";
|
|
NSNotificationName const VNADatabaseDidDeleteFolderNotification = @"Database Did Delete Folder";
|
|
|
|
/*!
|
|
* initialise the Database object with a specific path
|
|
*
|
|
* @param dbPath the path to the database we want to initialise
|
|
*
|
|
* @return an initialised Database object
|
|
*/
|
|
- (instancetype)initWithDatabaseAtPath:(NSString *)dbPath
|
|
{
|
|
self = [super init];
|
|
if (self) {
|
|
_initializedfoldersDict = NO;
|
|
_initializedSmartfoldersDict = NO;
|
|
_countOfUnread = 0;
|
|
_trashFolder = nil;
|
|
_searchFolder = nil;
|
|
_searchString = @"";
|
|
_smartfoldersDict = [[NSMutableDictionary alloc] init];
|
|
_foldersDict = [[NSMutableDictionary alloc] init];
|
|
[self initaliseFields];
|
|
_databaseQueue = [FMDatabaseQueue databaseQueueWithPath:dbPath];
|
|
// If we did not succeed getting read/write+create status,
|
|
// then we need to prompt the user for a different location.
|
|
if (_databaseQueue == nil) {
|
|
dbPath = [self relocateLockedDatabase:dbPath];
|
|
if (dbPath != nil) {
|
|
_databaseQueue = [FMDatabaseQueue databaseQueueWithPath:dbPath];
|
|
}
|
|
}
|
|
if (![self initialiseDatabase]) {
|
|
self = nil;
|
|
}
|
|
}
|
|
return self;
|
|
}
|
|
|
|
|
|
/* sharedManager
|
|
* Returns the single instance of the database manager.
|
|
*/
|
|
+ (instancetype)sharedManager {
|
|
static id sharedMyManager = nil;
|
|
static dispatch_once_t onceToken;
|
|
dispatch_once(&onceToken, ^{
|
|
sharedMyManager = [[Database alloc] initWithDatabaseAtPath:[Database databasePath]];
|
|
});
|
|
|
|
return sharedMyManager;
|
|
}
|
|
|
|
|
|
/*!
|
|
* Initialise the Vienna database. Create the initial database if
|
|
* necessary, otherwise migrate to the correct version if it is out of date
|
|
*
|
|
* @return YES if the database is at the correct version and good to go
|
|
*/
|
|
- (BOOL)initialiseDatabase {
|
|
NSInteger databaseVersion = self.databaseVersion;
|
|
os_log_debug(VNA_LOG, "Database version: %ld", databaseVersion);
|
|
|
|
if (databaseVersion >= VNACurrentDatabaseVersion) {
|
|
// Most common case, so it is first
|
|
// Nothing to do here
|
|
return YES;
|
|
} else if (databaseVersion >= VNAMinimumSupportedDatabaseVersion) {
|
|
NSAlert * alert = [[NSAlert alloc] init];
|
|
[alert setMessageText:NSLocalizedString(@"Database Upgrade", nil)];
|
|
[alert setInformativeText:NSLocalizedString(@"Vienna must upgrade its database to the latest version. This may take a minute or so. We apologize for the inconvenience.", nil)];
|
|
[alert addButtonWithTitle:NSLocalizedString(@"Upgrade Database", @"Title of a button on an alert")];
|
|
[alert addButtonWithTitle:NSLocalizedString(@"Quit Vienna", @"Title of a button on an alert")];
|
|
NSInteger modalReturn = [alert runModal];
|
|
if (modalReturn == NSAlertSecondButtonReturn) {
|
|
return NO;
|
|
}
|
|
|
|
// Back up the database before any upgrade.
|
|
NSFileManager *fileManager = NSFileManager.defaultManager;
|
|
NSString *databaseBackupPath = [[Database databasePath] stringByAppendingPathExtension:@"bak"];
|
|
NSError *error = nil;
|
|
if ([fileManager fileExistsAtPath:databaseBackupPath]) {
|
|
[fileManager removeItemAtPath:databaseBackupPath
|
|
error:&error];
|
|
}
|
|
[fileManager copyItemAtPath:[Database databasePath]
|
|
toPath:databaseBackupPath
|
|
error:&error];
|
|
|
|
// Log the error if the backup creation failed, but continue regardless.
|
|
if (error) {
|
|
NSLog(@"Database backup could not created: %@", error.localizedDescription);
|
|
}
|
|
|
|
[self.databaseQueue inDatabase:^(FMDatabase *db) {
|
|
// Migrate the database to the newest version
|
|
// TODO: move this into transaction so we can rollback on failure
|
|
[Database migrateDatabase:db fromVersion:databaseVersion];
|
|
}];
|
|
|
|
// Confirm the database is now at the correct version
|
|
if (self.databaseVersion == VNACurrentDatabaseVersion) {
|
|
return YES;
|
|
} else {
|
|
return NO;
|
|
}
|
|
} else if ((databaseVersion > 0) && (databaseVersion < VNAMinimumSupportedDatabaseVersion)) {
|
|
// database version is too old or schema not supported
|
|
// TODO: help text for the user to fix the issue
|
|
NSAlert *alert = [NSAlert new];
|
|
alert.alertStyle = NSAlertStyleCritical;
|
|
alert.messageText = NSLocalizedString(@"The database file format has changed", nil);
|
|
alert.informativeText = [NSString stringWithFormat:NSLocalizedString(@"The database (%@) file format is not supported by this version of Vienna. Delete or rename the file and restart Vienna.", nil), self.databaseQueue.path];
|
|
[alert runModal];
|
|
return NO;
|
|
} else if (databaseVersion == 0) {
|
|
// database is fresh
|
|
return [self setupInitialDatabase];
|
|
}
|
|
|
|
return NO;
|
|
}
|
|
|
|
/*!
|
|
* sets up an inital Vienna database at the given path
|
|
* TODO: put this into some Swift file to free VNACriteriaOperator enum from Objective-C legacy
|
|
*
|
|
* @return True on succes
|
|
*/
|
|
- (BOOL)setupInitialDatabase {
|
|
__block BOOL success = NO;
|
|
[self.databaseQueue inDatabase:^(FMDatabase *db) {
|
|
success = [self createTablesOnDatabase:db];
|
|
}];
|
|
if(!success) {
|
|
return NO;
|
|
}
|
|
|
|
// Create a criteria to find all marked articles
|
|
Criteria * markedCriteria = [[Criteria alloc] initWithField:MA_Field_Flagged operatorType:VNACriteriaOperatorEqualTo value:@"Yes"];
|
|
[self createInitialSmartFolder:NSLocalizedString(@"Marked Articles", nil) withCriteria:markedCriteria];
|
|
|
|
// Create a criteria to show all unread articles
|
|
Criteria * unreadCriteria = [[Criteria alloc] initWithField:MA_Field_Read operatorType:VNACriteriaOperatorEqualTo value:@"No"];
|
|
[self createInitialSmartFolder:NSLocalizedString(@"Unread Articles", nil) withCriteria:unreadCriteria];
|
|
|
|
// Create a criteria to show all articles received today
|
|
Criteria * todayCriteria = [[Criteria alloc] initWithField:MA_Field_Date operatorType:VNACriteriaOperatorEqualTo value:@"today"];
|
|
[self createInitialSmartFolder:NSLocalizedString(@"Today's Articles", nil) withCriteria:todayCriteria];
|
|
|
|
[self.databaseQueue inDatabase:^(FMDatabase *db) {
|
|
// Create the trash folder
|
|
[db executeUpdate:@"INSERT INTO folders (parent_id, foldername, unread_count, last_update, type, flags, next_sibling, first_child) VALUES (-1, ?, 0, 0, ?, 0, 0, 0)",
|
|
NSLocalizedString(@"Trash", nil), @(VNAFolderTypeTrash)];
|
|
|
|
// Set the initial version
|
|
db.userVersion = (uint32_t)VNACurrentDatabaseVersion;
|
|
|
|
// Set the default sort order and write it to both the db and the prefs
|
|
[db executeUpdate:@"INSERT INTO info (first_folder, folder_sort) VALUES (0, ?)", @(VNAFolderSortManual)];
|
|
[[Preferences standardPreferences] setFoldersTreeSortMethod:VNAFolderSortManual];
|
|
}];
|
|
|
|
// Set the initial folder order
|
|
[self initFolderArray];
|
|
NSInteger folderId = 0;
|
|
NSInteger previousSibling = 0;
|
|
NSArray * allFolders = self.foldersDict.allKeys;
|
|
NSUInteger count = allFolders.count;
|
|
NSUInteger index;
|
|
for (index = 0u; index < count; ++index) {
|
|
previousSibling = folderId;
|
|
folderId = [allFolders[index] integerValue];
|
|
if (index == 0u) {
|
|
[self setFirstChild:folderId forFolder:VNAFolderTypeRoot];
|
|
} else {
|
|
[self setNextSibling:folderId forFolder:previousSibling];
|
|
}
|
|
}
|
|
|
|
// If we have a DemoFeeds.plist in the resources then use it to create some initial demo
|
|
// RSS feeds.
|
|
NSBundle *thisBundle = [NSBundle bundleForClass:[self class]];
|
|
NSString * pathToPList = [thisBundle pathForResource:@"DemoFeeds.plist" ofType:@""];
|
|
if (pathToPList != nil) {
|
|
NSDictionary * demoFeedsDict = [NSDictionary dictionaryWithContentsOfFile:pathToPList];
|
|
if (demoFeedsDict) {
|
|
for (NSString * feedName in demoFeedsDict) {
|
|
NSDictionary * itemDict = demoFeedsDict[feedName];
|
|
NSString * feedURL = [itemDict valueForKey:@"URL"];
|
|
if (feedURL != nil && feedName != nil) {
|
|
previousSibling = [self addRSSFolder:feedName underParent:VNAFolderTypeRoot afterChild:previousSibling subscriptionURL:feedURL];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return YES;
|
|
}
|
|
|
|
|
|
-(BOOL)createTablesOnDatabase:(FMDatabase *)db {
|
|
// Enable FULL auto-vacuum mode before creating tables.
|
|
[db executeUpdate:@"PRAGMA auto_vacuum = 1"];
|
|
|
|
// Create the tables. We use the first table as a test whether we can
|
|
// setup at the specified location
|
|
[db executeUpdate:@"CREATE TABLE info (version, last_opened, first_folder, folder_sort)"];
|
|
if ([db hadError]) {
|
|
return NO;
|
|
}
|
|
[db executeUpdate:@"CREATE TABLE folders (folder_id integer primary key, parent_id, foldername, unread_count, last_update, type, flags, next_sibling, first_child)"];
|
|
[db executeUpdate:@"CREATE TABLE messages (message_id, folder_id, parent_id, read_flag, marked_flag, deleted_flag, title, sender, link, createddate, date, text, revised_flag, enclosuredownloaded_flag, hasenclosure_flag, enclosure)"];
|
|
[db executeUpdate:@"CREATE TABLE smart_folders (folder_id, search_string)"];
|
|
[db executeUpdate:@"CREATE TABLE rss_folders (folder_id, feed_url, username, last_update_string, description, home_page, bloglines_id)"];
|
|
[db executeUpdate:@"CREATE TABLE rss_guids (message_id, folder_id)"];
|
|
[db executeUpdate:@"CREATE INDEX messages_folder_idx ON messages (folder_id)"];
|
|
[db executeUpdate:@"CREATE INDEX messages_message_idx ON messages (message_id)"];
|
|
[db executeUpdate:@"CREATE INDEX rss_guids_idx ON rss_guids (folder_id)"];
|
|
[db executeUpdate:@"CREATE INDEX messages_read_flag ON messages(read_flag)"];
|
|
[db executeUpdate:@"CREATE INDEX messages_deleted_flag ON messages(deleted_flag)"];
|
|
if ([db hadError]) {
|
|
return NO;
|
|
}
|
|
return YES;
|
|
}
|
|
|
|
/*!
|
|
* Initialise the mappings between the names of
|
|
* the database fields and the model fields
|
|
*/
|
|
-(void)initaliseFields {
|
|
self.fieldsByName = [[NSMutableDictionary alloc] init];
|
|
self.fieldsOrdered = [[NSMutableArray alloc] init];
|
|
|
|
[self addField:MA_Field_Read type:VNAFieldTypeFlag tag:ArticleFieldIDRead sqlField:@"read_flag" visible:YES width:17];
|
|
[self addField:MA_Field_Flagged type:VNAFieldTypeFlag tag:ArticleFieldIDFlagged sqlField:@"marked_flag" visible:YES width:17];
|
|
[self addField:MA_Field_HasEnclosure type:VNAFieldTypeFlag tag:ArticleFieldIDHasEnclosure sqlField:@"hasenclosure_flag" visible:YES width:17];
|
|
[self addField:MA_Field_Deleted type:VNAFieldTypeFlag tag:ArticleFieldIDDeleted sqlField:@"deleted_flag" visible:NO width:15];
|
|
[self addField:MA_Field_Comments type:VNAFieldTypeInteger tag:ArticleFieldIDComments sqlField:@"comment_flag" visible:NO width:15];
|
|
[self addField:MA_Field_GUID type:VNAFieldTypeInteger tag:ArticleFieldIDGUID sqlField:@"message_id" visible:NO width:72];
|
|
[self addField:MA_Field_Subject type:VNAFieldTypeString tag:ArticleFieldIDSubject sqlField:@"title" visible:YES width:472];
|
|
[self addField:MA_Field_Folder type:VNAFieldTypeFolder tag:ArticleFieldIDFolder sqlField:@"folder_id" visible:NO width:130];
|
|
[self addField:MA_Field_Date type:VNAFieldTypeDate tag:ArticleFieldIDDate sqlField:@"date" visible:YES width:152];
|
|
[self addField:MA_Field_Parent type:VNAFieldTypeInteger tag:ArticleFieldIDParent sqlField:@"parent_id" visible:NO width:72];
|
|
[self addField:MA_Field_Author type:VNAFieldTypeString tag:ArticleFieldIDAuthor sqlField:@"sender" visible:YES width:138];
|
|
[self addField:MA_Field_Link type:VNAFieldTypeString tag:ArticleFieldIDLink sqlField:@"link" visible:NO width:138];
|
|
[self addField:MA_Field_Text type:VNAFieldTypeString tag:ArticleFieldIDText sqlField:@"text" visible:NO width:152];
|
|
[self addField:MA_Field_Summary type:VNAFieldTypeString tag:ArticleFieldIDSummary sqlField:@"summary" visible:NO width:152];
|
|
[self addField:MA_Field_Headlines type:VNAFieldTypeString tag:ArticleFieldIDHeadlines sqlField:@"" visible:NO width:100];
|
|
[self addField:MA_Field_Enclosure type:VNAFieldTypeString tag:ArticleFieldIDEnclosure sqlField:@"enclosure" visible:NO width:100];
|
|
[self addField:MA_Field_EnclosureDownloaded type:VNAFieldTypeFlag tag:ArticleFieldIDEnclosureDownloaded sqlField:@"enclosuredownloaded_flag" visible:NO width:100];
|
|
|
|
//set user friendly and localizable names for some fields
|
|
[self fieldByName:MA_Field_Read].displayName = NSLocalizedString(@"Read", @"Data field name visible in menu/smart folder definition");
|
|
[self fieldByName:MA_Field_Flagged].displayName = NSLocalizedString(@"Flagged", @"Data field name visible in menu/smart folder definition");
|
|
[self fieldByName:MA_Field_HasEnclosure].displayName = NSLocalizedString(@"Enclosure", @"Data field name (Y/N) visible in menu/smart folder definition");
|
|
[self fieldByName:MA_Field_Enclosure].displayName = NSLocalizedString(@"Enclosure URL", @"Data field name (URL) visible in menu/article list");
|
|
[self fieldByName:MA_Field_Deleted].displayName = NSLocalizedString(@"Deleted", @"Data field name visible in smart folder definition");
|
|
[self fieldByName:MA_Field_Subject].displayName = NSLocalizedString(@"Subject", @"Data field name visible in menu/article list/smart folder definition");
|
|
[self fieldByName:MA_Field_Folder].displayName = NSLocalizedString(@"Folder", @"Data field name visible in menu/article list/smart folder definition");
|
|
[self fieldByName:MA_Field_Date].displayName = NSLocalizedString(@"Date", @"Data field name visible in menu/article list/smart folder definition");
|
|
[self fieldByName:MA_Field_Author].displayName = NSLocalizedString(@"Author", @"Data field name visible in menu/article list/smart folder definition");
|
|
[self fieldByName:MA_Field_Text].displayName = NSLocalizedString(@"Text", @"Data field name visible in smart folder definition");
|
|
[self fieldByName:MA_Field_Summary].displayName = NSLocalizedString(@"Summary", @"Pseudo field name visible in menu/article list");
|
|
[self fieldByName:MA_Field_Headlines].displayName = NSLocalizedString(@"Headlines", @"Pseudo field name visible in article list");
|
|
[self fieldByName:MA_Field_Link].displayName = NSLocalizedString(@"Link", @"Data field name visible in menu/article list");
|
|
}
|
|
|
|
/* relocateLockedDatabase
|
|
* Tell the user that the database could not be created at the path specified by path
|
|
* and prompt for an alternative location. Opens and returns the new location if we were successful.
|
|
*/
|
|
-(NSString *)relocateLockedDatabase:(NSString *)path
|
|
{
|
|
FMDatabase *sqlDatabase = [FMDatabase databaseWithPath:[Database databasePath]];
|
|
|
|
NSAlert *alert = [NSAlert new];
|
|
alert.alertStyle = NSAlertStyleWarning;
|
|
alert.messageText = NSLocalizedString(@"Cannot create the Vienna database", nil);
|
|
alert.informativeText = [NSString stringWithFormat:NSLocalizedString(@"A new Vienna database cannot be created at \"%@\" because the folder is probably located on a remote network share and this version of Vienna cannot manage remote databases. Please choose an alternative folder that is located on your local machine.", nil), path];
|
|
[alert addButtonWithTitle:NSLocalizedString(@"Locate…", @"Title of a button on an alert")];
|
|
[alert addButtonWithTitle:NSLocalizedString(@"Cancel", @"Title of a button on an alert")];
|
|
NSModalResponse alertResponse = [alert runModal];
|
|
|
|
// When the cancel button is pressed.
|
|
if (alertResponse == NSAlertSecondButtonReturn) {
|
|
return nil;
|
|
}
|
|
|
|
// When the locate button is pressed.
|
|
if (alertResponse == NSAlertFirstButtonReturn) {
|
|
// Delete any existing database.
|
|
if (sqlDatabase != nil) {
|
|
[sqlDatabase close];
|
|
sqlDatabase = nil;
|
|
[[NSFileManager defaultManager] removeItemAtPath:path error:nil];
|
|
}
|
|
|
|
// Bring up modal UI to select the new location
|
|
NSOpenPanel * openPanel = [NSOpenPanel openPanel];
|
|
[openPanel setCanChooseFiles:NO];
|
|
[openPanel setCanChooseDirectories:YES];
|
|
if ([openPanel runModal] == NSModalResponseCancel) {
|
|
return nil;
|
|
}
|
|
|
|
// Make the new database name.
|
|
NSString * databaseName = path.lastPathComponent;
|
|
NSString * newPath = [openPanel.URLs[0].path stringByAppendingPathComponent:databaseName];
|
|
|
|
// And try to open it.
|
|
sqlDatabase = [[FMDatabase alloc] initWithPath:newPath];
|
|
if (!sqlDatabase || ![sqlDatabase open]) {
|
|
NSAlert *alert = [NSAlert new];
|
|
alert.alertStyle = NSAlertStyleCritical;
|
|
alert.messageText = NSLocalizedString(@"Sorry but Vienna was unable to open the database", nil);
|
|
alert.informativeText = [NSString stringWithFormat:NSLocalizedString(@"The database file (%@) could not be opened for some reason. It may be corrupted or inaccessible. Please delete or rename the database file and restart Vienna.", nil), newPath];
|
|
[alert runModal];
|
|
|
|
sqlDatabase = nil;
|
|
return nil;
|
|
}
|
|
|
|
// Save this to the preferences
|
|
[[Preferences standardPreferences] setDefaultDatabase:newPath];
|
|
[sqlDatabase close];
|
|
return newPath;
|
|
}
|
|
[sqlDatabase close];
|
|
return nil;
|
|
}
|
|
|
|
/* createInitialSmartFolder
|
|
* Create a smart folder in the database as part of creating a new database. This is intended to be
|
|
* called from the database initialisation code and makes a number of assumptions about the state
|
|
* of the class at that point. It is NOT a substitute for addSmartFolder. Specifically it does
|
|
* not add the new folder to the internal cache nor broadcast any notifications.
|
|
*/
|
|
-(void)createInitialSmartFolder:(NSString *)folderName withCriteria:(Criteria *)criteria
|
|
{
|
|
if ([self createFolderOnDatabase:folderName underParent:VNAFolderTypeRoot withType:VNAFolderTypeSmart] >= 0) {
|
|
CriteriaTree * criteriaTree = [[CriteriaTree alloc] init];
|
|
criteriaTree.criteriaTree = [criteriaTree.criteriaTree arrayByAddingObject:(id<CriteriaElement>)criteria];
|
|
|
|
__weak NSString * preparedCriteriaString = criteriaTree.string;
|
|
[self.databaseQueue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"INSERT INTO smart_folders (folder_id, search_string) VALUES (?, ?)", @(db.lastInsertRowId), preparedCriteriaString];
|
|
}];
|
|
}
|
|
}
|
|
|
|
/* syncLastUpdate
|
|
* Call this function to update the field in the info table which contains the last_updated
|
|
* date. This is basically auditing data and is only called when the database is first opened
|
|
* in this session.
|
|
*/
|
|
-(void)syncLastUpdate
|
|
{
|
|
__block BOOL success;
|
|
|
|
[self.databaseQueue inDatabase:^(FMDatabase *db) {
|
|
success = [db executeUpdate:@"UPDATE info SET last_opened=?", [NSDate date]];
|
|
|
|
}];
|
|
if (success) {
|
|
self.readOnly = NO;
|
|
} else {
|
|
self.readOnly = YES;
|
|
}
|
|
}
|
|
|
|
/* countOfUnread
|
|
* Return the total number of unread articles in the database.
|
|
*/
|
|
-(NSInteger)countOfUnread
|
|
{
|
|
[self initFolderArray];
|
|
return _countOfUnread;
|
|
}
|
|
|
|
/* addField
|
|
* Add the specified field to our fields array.
|
|
*/
|
|
-(void)addField:(NSString *)name type:(NSInteger)type tag:(NSInteger)tag sqlField:(NSString *)sqlField visible:(BOOL)visible width:(NSInteger)width
|
|
{
|
|
Field * field = [Field new];
|
|
if (field != nil) {
|
|
field.name = name;
|
|
field.type = type;
|
|
field.tag = tag;
|
|
field.visible = visible;
|
|
field.width = width;
|
|
field.sqlField = sqlField;
|
|
[self.fieldsOrdered addObject:field];
|
|
[self.fieldsByName setValue:field forKey:name];
|
|
}
|
|
}
|
|
|
|
/* arrayOfFields
|
|
* Return the array of fields.
|
|
*/
|
|
-(NSArray *)arrayOfFields
|
|
{
|
|
return self.fieldsOrdered;
|
|
}
|
|
|
|
/* fieldByTitle
|
|
* Given a name, this function returns the field represented by
|
|
* that name.
|
|
*/
|
|
-(Field *)fieldByName:(NSString *)name
|
|
{
|
|
return [self.fieldsByName valueForKey:name];
|
|
}
|
|
|
|
/*!
|
|
* Get the current database version from the database
|
|
* This method fetches the version information from both
|
|
* the SQLite PRAGMA user_version and the legacy Vienna info
|
|
* table, returning the highest version number.
|
|
*
|
|
* @return the current database version
|
|
*/
|
|
-(NSInteger)databaseVersion
|
|
{
|
|
__block NSInteger dbVersion = 0;
|
|
|
|
// FMDatabaseQueue *queue = [[Database sharedManager] self.databaseQueue];
|
|
[self.databaseQueue inDatabase:^(FMDatabase *db) {
|
|
FMResultSet * results = [db executeQuery:@"SELECT version FROM info"];
|
|
dbVersion = 0;
|
|
if ([results next]) {
|
|
dbVersion = [results intForColumn:@"version"];
|
|
}
|
|
[results close];
|
|
|
|
// compare the SQLite PRAGMA user_version to the legacy version number
|
|
if (db.userVersion > dbVersion) {
|
|
dbVersion = db.userVersion;
|
|
}
|
|
}];
|
|
|
|
return dbVersion;
|
|
}
|
|
|
|
/* compactDatabase
|
|
* Compact the database using the vacuum command.
|
|
*/
|
|
-(void)compactDatabase
|
|
{
|
|
if (!self.readOnly) {
|
|
[self.databaseQueue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"VACUUM"];
|
|
}];
|
|
}
|
|
}
|
|
|
|
/* reindexDatabase
|
|
* Reindex the database.
|
|
*/
|
|
-(void)reindexDatabase
|
|
{
|
|
if (!self.readOnly) {
|
|
[self.databaseQueue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"REINDEX"];
|
|
}];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clears a specified flag for the specified folder
|
|
*
|
|
* @param flag the flag to clear
|
|
* @param folderId the folder to clear the flag from
|
|
*/
|
|
-(void)clearFlag:(NSUInteger)flag forFolder:(NSInteger)folderId
|
|
{
|
|
// Exit now if we're read-only
|
|
if (self.readOnly) {
|
|
return;
|
|
}
|
|
|
|
Folder * folder = [self folderFromID:folderId];
|
|
if (folder != nil) {
|
|
[folder clearFlag:flag];
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"UPDATE folders SET flags=? WHERE folder_id=?", @(folder.flags), @(folderId)];
|
|
[[NSNotificationCenter defaultCenter] vna_postNotificationOnMainThreadWithName:MA_Notify_FoldersUpdated object:@(folderId)];
|
|
}];
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Sets the specified flag for the folder.
|
|
*
|
|
* @param flag flag to set
|
|
* @param folderId folder to set the flag for
|
|
*/
|
|
-(void)setFlag:(NSUInteger)flag forFolder:(NSInteger)folderId
|
|
{
|
|
// Exit now if we're read-only
|
|
if (self.readOnly) {
|
|
return;
|
|
}
|
|
|
|
Folder * folder = [self folderFromID:folderId];
|
|
if (folder != nil) {
|
|
[folder setFlag:flag];
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"UPDATE folders SET flags=? WHERE folder_id=?", @(folder.flags), @(folderId)];
|
|
[[NSNotificationCenter defaultCenter] vna_postNotificationOnMainThreadWithName:MA_Notify_FoldersUpdated object:@(folderId)];
|
|
}];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets the date when the folder was last updated.
|
|
*
|
|
* @param lastUpdate The date of the last update
|
|
* @param folderId The ID of the folder being updated
|
|
*/
|
|
-(void)setLastUpdate:(NSDate *)lastUpdate forFolder:(NSInteger)folderId
|
|
{
|
|
// Exit now if we're read-only
|
|
if (self.readOnly) {
|
|
return;
|
|
}
|
|
// If no change to last update, do nothing
|
|
Folder * folder = [self folderFromID:folderId];
|
|
if (folder != nil && (folder.type == VNAFolderTypeRSS || folder.type == VNAFolderTypeOpenReader)) {
|
|
if ([folder.lastUpdate isEqualToDate:lastUpdate]) {
|
|
return;
|
|
}
|
|
folder.lastUpdate = lastUpdate;
|
|
NSTimeInterval interval = lastUpdate.timeIntervalSince1970;
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"UPDATE folders SET last_update=? WHERE folder_id=?", @(interval), @(folderId)];
|
|
}];
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Sets the last update string for the folder.
|
|
*
|
|
* @param lastUpdateString The new last update string
|
|
* @param folderId The ID of the folder being updated
|
|
*/
|
|
-(void)setLastUpdateString:(NSString *)lastUpdateString forFolder:(NSInteger)folderId
|
|
{
|
|
// Exit now if we're read-only
|
|
if (self.readOnly) {
|
|
return;
|
|
}
|
|
// If no change to last update string, do nothing
|
|
Folder * folder = [self folderFromID:folderId];
|
|
if (folder != nil && (folder.type == VNAFolderTypeRSS || folder.type == VNAFolderTypeOpenReader)) {
|
|
if ([folder.lastUpdateString isEqualToString:lastUpdateString]) {
|
|
return;
|
|
}
|
|
|
|
folder.lastUpdateString = lastUpdateString;
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"UPDATE rss_folders SET last_update_string=? WHERE folder_id=?",
|
|
folder.lastUpdateString, @(folderId)];
|
|
}];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Change the URL of the feed on the specified RSS folder subscription.
|
|
*
|
|
* @param feed_url the URL to set the folder's feed to
|
|
* @param folderId the ID of the folder whose URL we are changing
|
|
*
|
|
* @return YES on success
|
|
*/
|
|
-(BOOL)setFeedURL:(NSString *)feed_url forFolder:(NSInteger)folderId
|
|
{
|
|
// Exit now if we're read-only
|
|
if (self.readOnly) {
|
|
return NO;
|
|
}
|
|
|
|
Folder * folder = [self folderFromID:folderId];
|
|
if (folder != nil && ![folder.feedURL isEqualToString:feed_url]) {
|
|
folder.feedURL = feed_url;
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"UPDATE rss_folders SET feed_url=? WHERE folder_id=?", feed_url, @(folderId)];
|
|
[[NSNotificationCenter defaultCenter] vna_postNotificationOnMainThreadWithName:MA_Notify_FoldersUpdated object:@(folderId)];
|
|
}];
|
|
}
|
|
return YES;
|
|
}
|
|
|
|
/**
|
|
* Sets the identifier of the feed to a value which is returned by the OpenReader server.
|
|
* Identifiers format vary with servers :
|
|
* - most common form is URL-based : feed/https://www.lemonde.fr/rss/une.xml
|
|
* - TheOldReader identifiers are based on hex data : feed/5bdadad8c70bc2e3ae000e3a
|
|
* - FreshRSS ones are based on decimal sequences per user : feed/16
|
|
*
|
|
* @param remoteId the identifier to set the folder's feed to
|
|
* @param folderId the ID of the folder whose remote identifier we are setting
|
|
*
|
|
* @return YES on success
|
|
*/
|
|
-(BOOL)setRemoteId:(NSString *)remoteId forFolder:(NSInteger)folderId
|
|
{
|
|
// Exit now if we're read-only
|
|
if (self.readOnly) {
|
|
return NO;
|
|
}
|
|
|
|
Folder * folder = [self folderFromID:folderId];
|
|
if (folder != nil && ![folder.remoteId isEqualToString:remoteId]) {
|
|
folder.remoteId = remoteId;
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"UPDATE rss_folders SET bloglines_id=? WHERE folder_id=?", remoteId, @(folderId)];
|
|
}];
|
|
}
|
|
return YES;
|
|
}
|
|
|
|
/*!
|
|
* Add a Google Reader RSS Feed folder and return the ID of the new folder.
|
|
*
|
|
* @param feedName The name of the RSS folder
|
|
* @param parentId The parent folder ID
|
|
* @param predecessorId The predecessor folder ID
|
|
* @param feed_url The URL of the RSS Feed folder
|
|
*
|
|
* @return The ID of the new folder
|
|
*/
|
|
-(NSInteger)addOpenReaderFolder:(NSString *)feedName underParent:(NSInteger)parentId afterChild:(NSInteger)predecessorId
|
|
subscriptionURL:(NSString *)feed_url remoteId:(NSString *)remoteId
|
|
{
|
|
NSInteger folderId =
|
|
[self addFolder:parentId afterChild:predecessorId folderName:feedName type:VNAFolderTypeOpenReader canAppendIndex:YES];
|
|
if (folderId != -1) {
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
__block BOOL success;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
success =
|
|
[db executeUpdate:
|
|
@"INSERT INTO rss_folders (folder_id, description, username, home_page, last_update_string, feed_url, bloglines_id) VALUES (?, ?, '', '', '', ?, ?)",
|
|
@(folderId),
|
|
feedName, // description
|
|
// username
|
|
// home_page
|
|
// last_update_string
|
|
feed_url,
|
|
remoteId];
|
|
}];
|
|
if (!success) {
|
|
return -1;
|
|
}
|
|
|
|
// Add this new folder to our internal cache
|
|
Folder *folder = [self folderFromID:folderId];
|
|
folder.feedDescription = feedName;
|
|
folder.feedURL = feed_url;
|
|
folder.remoteId = remoteId;
|
|
}
|
|
return folderId;
|
|
} /* addOpenReaderFolder */
|
|
|
|
/*!
|
|
* Add a RSS Feed folder and return the ID of the new folder.
|
|
*
|
|
* @param feedName The name of the RSS folder
|
|
* @param parentId The parent folder ID
|
|
* @param predecessorId The predecessor folder ID
|
|
* @param feed_url The URL of the RSS Feed folder
|
|
*
|
|
* @return The ID of the new folder
|
|
*/
|
|
-(NSInteger)addRSSFolder:(NSString *)feedName underParent:(NSInteger)parentId afterChild:(NSInteger)predecessorId subscriptionURL:(NSString *)feed_url
|
|
{
|
|
NSInteger folderId = [self addFolder:parentId afterChild:predecessorId folderName:feedName type:VNAFolderTypeRSS canAppendIndex:YES];
|
|
if (folderId != -1) {
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
__block BOOL success;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
success = [db executeUpdate:@"INSERT INTO rss_folders (folder_id, description, username, home_page, last_update_string, feed_url, bloglines_id) "
|
|
"VALUES (?, '', '', '', '', ?, 0)",
|
|
@(folderId),
|
|
feed_url];
|
|
}];
|
|
|
|
if (!success) {
|
|
return -1;
|
|
}
|
|
|
|
// Add this new folder to our internal cache
|
|
Folder * folder = [self folderFromID:folderId];
|
|
folder.feedURL = feed_url;
|
|
}
|
|
return folderId;
|
|
}
|
|
|
|
/* addFolder
|
|
* Create a new folder under the specified parent and give it the requested name and type. If
|
|
* canAppendIndex is YES then we adjust the name to ensure that the folder name remains unique. If
|
|
* we hit an error, the function returns -1.
|
|
*/
|
|
-(NSInteger)addFolder:(NSInteger)parentId afterChild:(NSInteger)predId folderName:(NSString *)name type:(NSInteger)type canAppendIndex:(BOOL)canAppendIndex
|
|
{
|
|
__block NSInteger predecessorId = predId;
|
|
Folder * folder = nil;
|
|
|
|
// Prime the cache
|
|
[self initFolderArray];
|
|
|
|
// Exit now if we're read-only
|
|
if (self.readOnly) {
|
|
return -1;
|
|
}
|
|
|
|
if (!canAppendIndex) {
|
|
folder = [self folderFromName:name];
|
|
if (folder) {
|
|
return folder.itemId;
|
|
}
|
|
} else {
|
|
// If a folder of that name already exists then adjust the name by appending
|
|
// an index number to make it unique.
|
|
NSString * oldName = name;
|
|
NSUInteger index = 1;
|
|
|
|
while (([self folderFromName:name]) != nil) {
|
|
name = [NSString stringWithFormat:@"%@ (%lu)", oldName, (unsigned long)index++];
|
|
}
|
|
}
|
|
|
|
NSInteger nextSibling = 0;
|
|
BOOL manualSort = [Preferences standardPreferences].foldersTreeSortMethod == VNAFolderSortManual;
|
|
if (manualSort) {
|
|
if (predecessorId > 0) {
|
|
Folder * predecessor = [self folderFromID:predecessorId];
|
|
if (predecessor != nil) {
|
|
nextSibling = predecessor.nextSiblingId;
|
|
} else {
|
|
predecessorId = 0;
|
|
}
|
|
}
|
|
if (predecessorId < 0) {
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
FMResultSet * siblings = [db executeQuery:@"SELECT folder_id FROM folders WHERE parent_id=? AND next_sibling=0", @(parentId)];
|
|
if([siblings next]) {
|
|
predecessorId = [siblings intForColumn:@"folder_id"];
|
|
} else {
|
|
predecessorId = 0;
|
|
}
|
|
[siblings close];
|
|
}];
|
|
}
|
|
if (predecessorId == 0) {
|
|
if (parentId == VNAFolderTypeRoot) {
|
|
nextSibling = self.firstFolderId;
|
|
} else {
|
|
Folder * parent = [self folderFromID:parentId];
|
|
if (parent != nil) {
|
|
nextSibling = parent.firstChildId;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Here we create the folder anew.
|
|
NSInteger newItemId = [self createFolderOnDatabase:name underParent:parentId withType:type];
|
|
if (newItemId != -1) {
|
|
// Add this new folder to our internal cache. If this is an RSS or Open Reader
|
|
// folder, mark it so that somewhere down the line we'll request the
|
|
// image for the folder.
|
|
folder = [[Folder alloc] initWithId:newItemId parentId:parentId name:name type:type];
|
|
if ((type == VNAFolderTypeRSS)||(type == VNAFolderTypeOpenReader)) {
|
|
[folder setFlag:VNAFolderFlagCheckForImage];
|
|
}
|
|
self.foldersDict[@(newItemId)] = folder;
|
|
|
|
if (manualSort) {
|
|
if (nextSibling > 0) {
|
|
[self setNextSibling:nextSibling forFolder:newItemId];
|
|
}
|
|
if (predecessorId > 0) {
|
|
[self setNextSibling:newItemId forFolder:predecessorId];
|
|
} else {
|
|
[self setFirstChild:newItemId forFolder:parentId];
|
|
}
|
|
}
|
|
|
|
// Send a notification when new folders are added
|
|
[[NSNotificationCenter defaultCenter] vna_postNotificationOnMainThreadWithName:MA_Notify_FolderAdded object:folder];
|
|
}
|
|
return newItemId;
|
|
}
|
|
|
|
/* createFolderOnDatabase:underParent:withType
|
|
* Generic (and internal!) function that creates a new folder in the database. It just creates
|
|
* the folder without any real sanity checks which are assumed to have been done by the caller.
|
|
* Returns the ID of the newly created folder or -1 if we failed.
|
|
*/
|
|
-(NSInteger)createFolderOnDatabase:(NSString *)name underParent:(NSInteger)parentId withType:(NSInteger)type
|
|
{
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
|
|
__block NSInteger newItemId = -1;
|
|
NSInteger flags = 0;
|
|
NSInteger nextSibling = 0;
|
|
NSInteger firstChild = 0;
|
|
|
|
// For new folders, last update is set to before now
|
|
NSDate * lastUpdate = [NSDate distantPast];
|
|
NSTimeInterval interval = lastUpdate.timeIntervalSince1970;
|
|
|
|
// Require an image check if we're a subscription folder
|
|
if ((type == VNAFolderTypeRSS) || (type == VNAFolderTypeOpenReader)) {
|
|
flags = VNAFolderFlagCheckForImage;
|
|
}
|
|
// Create the folder in the database. One thing to watch out for here that has
|
|
// bit me before. When adding new fields to the folders table, remember to init
|
|
// the field here even if its just to an empty value.
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
BOOL success = [db executeUpdate:
|
|
@"INSERT INTO folders (foldername, parent_id, unread_count, last_update, type, flags, next_sibling, first_child) VALUES(?, ?, 0, ?, ?, ?, ?, ?)",
|
|
name,
|
|
@(parentId),
|
|
// unread_count = 0
|
|
@(interval),
|
|
@(type),
|
|
@(flags),
|
|
@(nextSibling),
|
|
@(firstChild)];
|
|
|
|
// Quick way of getting the last autoincrement primary key value (the folder_id).
|
|
if (success) {
|
|
newItemId = db.lastInsertRowId;
|
|
}
|
|
}];
|
|
return newItemId;
|
|
}
|
|
|
|
/* untitledFeedFolderName
|
|
* Returns the name given to untitled feed folders.
|
|
*/
|
|
+(NSString *)untitledFeedFolderName
|
|
{
|
|
return NSLocalizedString(@"(Untitled Feed)", nil);
|
|
}
|
|
|
|
/* wrappedDeleteFolder
|
|
* Delete the specified folder. This function can be very SQL intensive.
|
|
*/
|
|
-(BOOL)wrappedDeleteFolder:(NSInteger)folderId
|
|
{
|
|
NSArray * arrayOfChildFolders = [self arrayOfFolders:folderId];
|
|
Folder * folder;
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
|
|
// Recurse and delete child folders
|
|
for (folder in arrayOfChildFolders) {
|
|
[self wrappedDeleteFolder:folder.itemId];
|
|
}
|
|
|
|
// Adjust unread counts on parents
|
|
folder = [self folderFromID:folderId];
|
|
NSInteger adjustment = -folder.unreadCount;
|
|
folder = [self folderFromID:folder.parentId];
|
|
while (folder != nil) {
|
|
folder.childUnreadCount = folder.childUnreadCount + adjustment;
|
|
folder = [self folderFromID:folder.parentId];
|
|
}
|
|
|
|
// Delete all articles in this folder then delete ourselves.
|
|
folder = [self folderFromID:folderId];
|
|
_countOfUnread -= folder.unreadCount;
|
|
if (folder.type == VNAFolderTypeSmart) {
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"DELETE FROM smart_folders WHERE folder_id=?", @(folderId)];
|
|
}];
|
|
}
|
|
|
|
// If this is an RSS feed, delete from the feeds
|
|
// and delete raw feed source
|
|
if (folder.type == VNAFolderTypeRSS || folder.type == VNAFolderTypeOpenReader) {
|
|
[queue inTransaction:^(FMDatabase *db, BOOL * rollback) {
|
|
[db executeUpdate:@"DELETE FROM rss_folders WHERE folder_id=?", @(folderId)];
|
|
[db executeUpdate:@"DELETE FROM rss_guids WHERE folder_id=?", @(folderId)];
|
|
}];
|
|
|
|
NSString * feedSourceFilePath = folder.feedSourceFilePath;
|
|
if (feedSourceFilePath != nil) {
|
|
BOOL isDirectory = YES;
|
|
if ([[NSFileManager defaultManager] fileExistsAtPath:feedSourceFilePath isDirectory:&isDirectory] && !isDirectory) {
|
|
[[NSFileManager defaultManager] removeItemAtPath:feedSourceFilePath error:NULL];
|
|
}
|
|
|
|
NSString * backupPath = [feedSourceFilePath stringByAppendingPathExtension:@"bak"];
|
|
if ([[NSFileManager defaultManager] fileExistsAtPath:backupPath isDirectory:&isDirectory] && !isDirectory) {
|
|
[[NSFileManager defaultManager] removeItemAtPath:backupPath error:NULL];
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we deleted the search folder, null out our cached handle
|
|
if (folder.type == VNAFolderTypeSearch) {
|
|
[self setSearchFolder:nil];
|
|
}
|
|
|
|
// Update the sort order if necessary
|
|
if ([Preferences standardPreferences].foldersTreeSortMethod == VNAFolderSortManual) {
|
|
__block NSInteger previousSibling = -999;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
FMResultSet * results = [db executeQuery:@"SELECT folder_id FROM folders WHERE parent_id=? AND next_sibling=?", @(folder.parentId), @(folderId)];
|
|
if ([results next]) {
|
|
previousSibling = [results intForColumn:@"folder_id"];
|
|
}
|
|
[results close];
|
|
}];
|
|
if (previousSibling != -999) {
|
|
[self setNextSibling:folder.nextSiblingId forFolder:previousSibling];
|
|
} else {
|
|
[self setFirstChild:folder.nextSiblingId forFolder:folder.parentId];
|
|
}
|
|
|
|
|
|
}
|
|
|
|
// For a smart folder, the next line is a no-op but it helpfully takes care of the case where a
|
|
// normal folder had it's type grobbed to VNAFolderTypeSmart.
|
|
[queue inTransaction:^(FMDatabase *db, BOOL *rollback) {
|
|
[db executeUpdate:@"DELETE FROM messages WHERE folder_id=?", @(folderId)];
|
|
[db executeUpdate:@"DELETE FROM folders WHERE folder_id=?", @(folderId)];
|
|
}];
|
|
|
|
// Remove from the folders array. Do this after we send the notification
|
|
// so that the notification handlers don't fail if they try to dereference the
|
|
// folder.
|
|
[self.foldersDict removeObjectForKey:@(folderId)];
|
|
return YES;
|
|
}
|
|
|
|
/* deleteFolder
|
|
* Delete the specified folder. If the folder has any children, delete them too. Also delete
|
|
* all articles associated with the folder. Then send a notification that the folder went bye-bye.
|
|
*/
|
|
-(BOOL)deleteFolder:(NSInteger)folderId
|
|
{
|
|
NSMutableArray * arrayOfFolderIds;
|
|
NSArray * arrayOfChildFolders;
|
|
NSNumber * numFolder;
|
|
Folder * folder;
|
|
BOOL result;
|
|
|
|
// Exit now if we're read-only
|
|
if (self.readOnly) {
|
|
return NO;
|
|
}
|
|
|
|
// Make sure this is a valid folder
|
|
folder = [self folderFromID:folderId];
|
|
if (folder == nil) {
|
|
return NO;
|
|
}
|
|
|
|
arrayOfChildFolders = [self arrayOfSubFolders:folder];
|
|
arrayOfFolderIds = [NSMutableArray arrayWithCapacity:arrayOfChildFolders.count];
|
|
|
|
// Send the pre-delete notification before we start the transaction so that the handlers can
|
|
// safely do any database access.
|
|
for (folder in arrayOfChildFolders) {
|
|
numFolder = @(folder.itemId);
|
|
[arrayOfFolderIds addObject:numFolder];
|
|
[[NSNotificationCenter defaultCenter] vna_postNotificationOnMainThreadWithName:VNADatabaseWillDeleteFolderNotification object:numFolder];
|
|
}
|
|
|
|
// Now do the deletion.
|
|
result = [self wrappedDeleteFolder:folderId];
|
|
|
|
// Send the post-delete notification after we're finished. Note that the folder actually corresponding to
|
|
// each numFolder won't exist any more and the handlers need to be aware of this.
|
|
for (numFolder in arrayOfFolderIds) {
|
|
[[NSNotificationCenter defaultCenter] vna_postNotificationOnMainThreadWithName:VNADatabaseDidDeleteFolderNotification object:numFolder];
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
|
|
/**
|
|
* Renames the specified folder.
|
|
*
|
|
* @param newName the name name for the folder
|
|
* @param folderId the ID of the folder to rename
|
|
*
|
|
* @return YES on success
|
|
*/
|
|
-(BOOL)setName:(NSString *)newName forFolder:(NSInteger)folderId
|
|
{
|
|
// Exit now if we're read-only
|
|
if (self.readOnly) {
|
|
return NO;
|
|
}
|
|
|
|
// Find our folder element.
|
|
Folder * folder = [self folderFromID:folderId];
|
|
if (!folder) {
|
|
return NO;
|
|
}
|
|
|
|
// Do nothing if the name hasn't changed. Otherwise it is wasted
|
|
// effort, basically.
|
|
if ([folder.name isEqualToString:newName]) {
|
|
return NO;
|
|
}
|
|
|
|
folder.name = newName;
|
|
|
|
// Rename in the database
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"UPDATE folders SET foldername=? WHERE folder_id=?", newName, @(folderId)];
|
|
}];
|
|
|
|
// Send a notification that the folder has changed. It is the responsibility of the
|
|
// notifiee that they work out that the name is the part that has changed.
|
|
[[NSNotificationCenter defaultCenter] vna_postNotificationOnMainThreadWithName:MA_Notify_FolderNameChanged object:@(folderId)];
|
|
return YES;
|
|
}
|
|
|
|
|
|
/**
|
|
* Sets the folder description both in the internal structure and in the folder_description table.
|
|
*
|
|
* @param newDescription The new description for the folder
|
|
* @param folderId The ID of the folder
|
|
*
|
|
* @return YES on success
|
|
*/
|
|
-(BOOL)setDescription:(NSString *)newDescription forFolder:(NSInteger)folderId
|
|
{
|
|
// Exit now if we're read-only
|
|
if (self.readOnly) {
|
|
return NO;
|
|
}
|
|
// Find our folder element.
|
|
Folder * folder = [self folderFromID:folderId];
|
|
if (!folder) {
|
|
return NO;
|
|
}
|
|
|
|
// Do nothing if the description hasn't changed. Otherwise it is wasted
|
|
// effort, basically.
|
|
if ([folder.feedDescription isEqualToString:newDescription]) {
|
|
return NO;
|
|
}
|
|
|
|
folder.feedDescription = newDescription;
|
|
|
|
// Add a new description or update the one we have
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"UPDATE rss_folders SET description=? WHERE folder_id=?",
|
|
newDescription, @(folderId)];
|
|
}];
|
|
|
|
// Send a notification that the folder has changed. It is the responsibility of the
|
|
// notifiee that they work out that the description is the part that has changed.
|
|
[[NSNotificationCenter defaultCenter] vna_postNotificationOnMainThreadWithName:MA_Notify_FolderDescriptionChanged
|
|
object:@(folderId)];
|
|
return YES;
|
|
}
|
|
|
|
|
|
/**
|
|
* Sets the folder's associated home page URL link in both in the internal
|
|
* structure and in the folder_description table.
|
|
*
|
|
* @param homePageURL The home page URL
|
|
* @param folderId The ID of the folder getting updated
|
|
*
|
|
* @return YES on success
|
|
*/
|
|
-(BOOL)setHomePage:(NSString *)homePageURL forFolder:(NSInteger)folderId
|
|
{
|
|
// Exit now if we're read-only
|
|
if (self.readOnly) {
|
|
return NO;
|
|
}
|
|
// Find our folder element.
|
|
Folder * folder = [self folderFromID:folderId];
|
|
if (!folder) {
|
|
return NO;
|
|
}
|
|
|
|
// Do nothing if the link hasn't changed. Otherwise it is wasted
|
|
// effort, basically.
|
|
if ([folder.homePage isEqualToString:homePageURL] || homePageURL==nil) {
|
|
return NO;
|
|
}
|
|
|
|
folder.homePage = homePageURL;
|
|
|
|
// Add a new link or update the one we have
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"UPDATE rss_folders SET home_page=? WHERE folder_id=?",
|
|
homePageURL, @(folderId)];
|
|
}];
|
|
|
|
|
|
// Send a notification that the folder has changed. It is the responsibility of the
|
|
// notifiee that they work out that the link is the part that has changed.
|
|
[[NSNotificationCenter defaultCenter] vna_postNotificationOnMainThreadWithName:MA_Notify_FolderHomePageChanged
|
|
object:@(folderId)];
|
|
return YES;
|
|
}
|
|
|
|
/* setFolderUsername
|
|
* Sets the folder's user name in both in the internal structure and in the folder_description table.
|
|
*/
|
|
-(BOOL)setFolderUsername:(NSInteger)folderId newUsername:(NSString *)name
|
|
{
|
|
// Exit now if we're read-only
|
|
if (self.readOnly) {
|
|
return NO;
|
|
}
|
|
|
|
// Find our folder element.
|
|
Folder * folder = [self folderFromID:folderId];
|
|
if (!folder) {
|
|
return NO;
|
|
}
|
|
|
|
// Do nothing if the link hasn't changed. Otherwise it is wasted
|
|
// effort, basically.
|
|
if ([folder.username isEqualToString:name]) {
|
|
return NO;
|
|
}
|
|
|
|
folder.username = name;
|
|
|
|
// Add a new link or update the one we have
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"UPDATE rss_folders SET username=? WHERE folder_id=?",
|
|
name, @(folderId)];
|
|
}];
|
|
|
|
return YES;
|
|
}
|
|
|
|
/* setParent
|
|
* Changes the parent for the specified folder then updates the database.
|
|
*/
|
|
-(BOOL)setParent:(NSInteger)newParentID forFolder:(NSInteger)folderId
|
|
{
|
|
// Exit now if we're read-only
|
|
if (self.readOnly) {
|
|
return NO;
|
|
}
|
|
|
|
Folder * folder = [self folderFromID:folderId];
|
|
if (folder.parentId == newParentID) {
|
|
return NO;
|
|
}
|
|
|
|
// Sanity check. Make sure we're not reparenting to our
|
|
// subordinate.
|
|
Folder * parentFolder = [self folderFromID:newParentID];
|
|
while (parentFolder != nil) {
|
|
if (parentFolder.itemId == folderId) {
|
|
return NO;
|
|
}
|
|
parentFolder = [self folderFromID:parentFolder.parentId];
|
|
}
|
|
|
|
// Adjust the child unread count for the old parent.
|
|
NSInteger adjustment = 0;
|
|
if (folder.type == VNAFolderTypeRSS || folder.type == VNAFolderTypeOpenReader) {
|
|
adjustment = folder.unreadCount;
|
|
} else if (folder.groupFolder) {
|
|
adjustment = folder.childUnreadCount;
|
|
}
|
|
if (adjustment > 0) {
|
|
parentFolder = [self folderFromID:folder.parentId];
|
|
while (parentFolder != nil) {
|
|
parentFolder.childUnreadCount = parentFolder.childUnreadCount - adjustment;
|
|
parentFolder = [self folderFromID:parentFolder.parentId];
|
|
}
|
|
}
|
|
|
|
// Do the re-parent
|
|
folder.parentId = newParentID;
|
|
|
|
// In addition to reparenting the child, we also need to fix up the unread count for all
|
|
// precedent parents.
|
|
if (adjustment > 0) {
|
|
parentFolder = [self folderFromID:newParentID];
|
|
while (parentFolder != nil) {
|
|
parentFolder.childUnreadCount = parentFolder.childUnreadCount + adjustment;
|
|
parentFolder = [self folderFromID:parentFolder.parentId];
|
|
}
|
|
}
|
|
|
|
// Update the database now
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"UPDATE folders SET parent_id=? WHERE folder_id=?",
|
|
@(newParentID), @(folderId)];
|
|
}];
|
|
return YES;
|
|
}
|
|
|
|
/* setFirstChild
|
|
* Changes the first child of the specified folder and then updates the database.
|
|
*/
|
|
-(BOOL)setFirstChild:(NSInteger)childId forFolder:(NSInteger)folderId
|
|
{
|
|
// Exit now if we're read-only
|
|
if (self.readOnly) {
|
|
return NO;
|
|
}
|
|
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
|
|
if (folderId == VNAFolderTypeRoot) {
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"UPDATE info SET first_folder=?", @(childId)];
|
|
}];
|
|
} else {
|
|
Folder * folder = [self folderFromID:folderId];
|
|
if (folder == nil) {
|
|
return NO;
|
|
}
|
|
|
|
folder.firstChildId = childId;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"UPDATE folders SET first_child=? WHERE folder_id=?",
|
|
@(childId), @(folderId)];
|
|
}];
|
|
}
|
|
|
|
return YES;
|
|
}
|
|
|
|
/* setNextSibling
|
|
* Changes the next sibling for the specified folder and then updates the database.
|
|
*/
|
|
-(BOOL)setNextSibling:(NSUInteger)nextSiblingId forFolder:(NSInteger)folderId
|
|
{
|
|
// Exit now if we're read-only
|
|
if (self.readOnly) {
|
|
return NO;
|
|
}
|
|
|
|
Folder * folder = [self folderFromID:folderId];
|
|
if (folder == nil) {
|
|
return NO;
|
|
}
|
|
|
|
folder.nextSiblingId = nextSiblingId;
|
|
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"UPDATE folders SET next_sibling=? WHERE folder_id=?",
|
|
@(nextSiblingId), @(folderId)];
|
|
}];
|
|
|
|
return YES;
|
|
}
|
|
|
|
/* firstFolderId
|
|
* Returns the ID of the first folder (first child of root).
|
|
*/
|
|
-(NSInteger)firstFolderId
|
|
{
|
|
__block NSInteger folderId = 0;
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
FMResultSet * results = [db executeQuery:@"SELECT first_folder FROM info"];
|
|
if ([results next]) {
|
|
folderId = [results intForColumn:@"first_folder"];
|
|
}
|
|
[results close];
|
|
}];
|
|
|
|
return folderId;
|
|
}
|
|
|
|
/* trashFolderId;
|
|
* Returns the ID of the trash folder.
|
|
*/
|
|
-(NSInteger)trashFolderId
|
|
{
|
|
return self.trashFolder.itemId;
|
|
}
|
|
|
|
/* searchFolderId;
|
|
* Returns the ID of the search folder. If it doesn't exist then we create
|
|
* it now.
|
|
*/
|
|
-(NSInteger)searchFolderId
|
|
{
|
|
if (self.searchFolder == nil) {
|
|
NSInteger folderId = [self addFolder:VNAFolderTypeRoot afterChild:0 folderName: NSLocalizedString(@"Search Results", nil) type:VNAFolderTypeSearch canAppendIndex:YES];
|
|
self.searchFolder = [self folderFromID:folderId];
|
|
}
|
|
return self.searchFolder.itemId;
|
|
}
|
|
|
|
/* folderFromID
|
|
* Retrieve a Folder given its ID.
|
|
*/
|
|
-(Folder *)folderFromID:(NSInteger)wantedId
|
|
{
|
|
return self.foldersDict[@(wantedId)];
|
|
}
|
|
|
|
/* folderFromName
|
|
* Retrieve a Folder given its name.
|
|
*/
|
|
-(Folder *)folderFromName:(NSString *)wantedName
|
|
{
|
|
Folder * folder;
|
|
for (folder in [self.foldersDict objectEnumerator]) {
|
|
if ([folder.name isEqualToString:wantedName]) {
|
|
break;
|
|
}
|
|
}
|
|
return folder;
|
|
}
|
|
|
|
|
|
/*!
|
|
* folderFromFeedURL
|
|
*
|
|
* @param wantedFeedURL The feed URL the folder is wanted for
|
|
*
|
|
* @return An RSSFolder that is subscribed to the specified feed URL.
|
|
*/
|
|
-(Folder *)folderFromFeedURL:(NSString *)wantedFeedURL
|
|
{
|
|
Folder * folder;
|
|
|
|
for (folder in [self.foldersDict objectEnumerator]) {
|
|
if ([folder.feedURL isEqualToString:wantedFeedURL]) {
|
|
break;
|
|
}
|
|
}
|
|
return folder;
|
|
}
|
|
|
|
/*!
|
|
* folderFromRemoteId
|
|
*
|
|
* @param wantedRemoteId The remote identifier the folder is wanted for
|
|
*
|
|
* @return An OpenReaderFolder that corresponds
|
|
*/
|
|
|
|
-(Folder *)folderFromRemoteId:(NSString *)wantedRemoteId
|
|
{
|
|
Folder * folder;
|
|
|
|
for (folder in [self.foldersDict objectEnumerator]) {
|
|
if ([folder.remoteId isEqualToString:wantedRemoteId]) {
|
|
break;
|
|
}
|
|
}
|
|
return folder;
|
|
}
|
|
|
|
/* handleAutoSortFoldersTreeChange
|
|
* Called when preference changes for sorting folders tree.
|
|
* Store the new method in the database.
|
|
*/
|
|
-(void)handleAutoSortFoldersTreeChange:(NSNotification *)notification
|
|
{
|
|
if (!self.readOnly) {
|
|
[self.databaseQueue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"UPDATE info SET folder_sort=?", @([Preferences standardPreferences].foldersTreeSortMethod)];
|
|
}];
|
|
}
|
|
}
|
|
|
|
/* addArticle
|
|
* Adds an article in the specified folder. Returns YES if the
|
|
* article was added or NO if we couldn't add the article for
|
|
* some reason.
|
|
*/
|
|
-(BOOL)addArticle:(Article *)article toFolder:(NSInteger)folderID
|
|
{
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
|
|
// Exit now if we're read-only
|
|
if (self.readOnly) {
|
|
return NO;
|
|
}
|
|
|
|
// Extract the article data from the dictionary.
|
|
NSString * articleBody = article.body;
|
|
NSString * articleTitle = article.title;
|
|
NSDate * articleDate = article.date;
|
|
NSString * articleLink = article.link.vna_trimmed;
|
|
NSString * userName = article.author.vna_trimmed;
|
|
NSString * articleEnclosure = article.enclosure.vna_trimmed;
|
|
NSString * articleGuid = article.guid;
|
|
NSInteger parentId = article.parentId;
|
|
BOOL marked_flag = article.flagged;
|
|
BOOL read_flag = article.read;
|
|
BOOL revised_flag = article.revised;
|
|
BOOL deleted_flag = article.deleted;
|
|
BOOL hasenclosure_flag = article.hasEnclosure;
|
|
|
|
// We always set the created date ourselves
|
|
article.createdDate = [NSDate date];
|
|
|
|
// Set some defaults
|
|
if (articleDate == nil) {
|
|
articleDate = [NSDate date];
|
|
}
|
|
if (userName == nil) {
|
|
userName = @"";
|
|
}
|
|
|
|
// Parse off the title
|
|
if (articleTitle == nil || articleTitle.vna_isBlank) {
|
|
articleTitle = [NSString vna_stringByRemovingHTML:articleBody].vna_firstNonBlankLine;
|
|
}
|
|
|
|
// Dates are stored as time intervals
|
|
NSTimeInterval interval = articleDate.timeIntervalSince1970;
|
|
NSTimeInterval createdInterval = article.createdDate.timeIntervalSince1970;
|
|
|
|
__block BOOL success;
|
|
[queue inTransaction:^(FMDatabase *db, BOOL *rollback) {
|
|
success = [db executeUpdate:@"INSERT INTO messages (message_id, parent_id, folder_id, sender, link, date, createddate, read_flag, marked_flag, deleted_flag, title, text, revised_flag, enclosure, hasenclosure_flag) "
|
|
@"VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
articleGuid,
|
|
@(parentId),
|
|
@(folderID),
|
|
userName,
|
|
articleLink,
|
|
@(interval),
|
|
@(createdInterval),
|
|
@(read_flag),
|
|
@(marked_flag),
|
|
@(deleted_flag),
|
|
articleTitle,
|
|
articleBody,
|
|
@(revised_flag),
|
|
articleEnclosure,
|
|
@(hasenclosure_flag)];
|
|
if (!success) {
|
|
NSLog(@"error = %@", [db lastErrorMessage]);
|
|
*rollback = YES;
|
|
return;
|
|
}
|
|
|
|
success = [db executeUpdate:@"INSERT INTO rss_guids (message_id, folder_id) VALUES (?, ?)", articleGuid, @(folderID)];
|
|
if (!success) {
|
|
NSLog(@"error = %@", [db lastErrorMessage]);
|
|
*rollback = YES;
|
|
return;
|
|
}
|
|
|
|
}];
|
|
return (success);
|
|
}
|
|
|
|
/* updateArticle
|
|
* Updates an article in the specified folder. Returns YES if the
|
|
* article was updated or NO if we couldn't update the article for
|
|
* some reason.
|
|
*/
|
|
-(BOOL)updateArticle:(Article *)existingArticle ofFolder:(NSInteger)folderID withArticle:(Article *)article
|
|
{
|
|
// Exit now if we're read-only
|
|
if (self.readOnly) {
|
|
return NO;
|
|
}
|
|
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
|
|
// Extract the data from the new state of article
|
|
NSString * articleBody = article.body;
|
|
NSString * articleTitle = article.title;
|
|
NSDate * articleDate = article.date;
|
|
NSString * articleLink = article.link.vna_trimmed;
|
|
NSString * userName = article.author.vna_trimmed;
|
|
NSString * articleGuid = article.guid;
|
|
NSInteger parentId = article.parentId;
|
|
BOOL revised_flag = article.revised;
|
|
|
|
// Set some defaults
|
|
if (articleDate == nil) {
|
|
articleDate = existingArticle.date;
|
|
}
|
|
if (userName == nil) {
|
|
userName = @"";
|
|
}
|
|
|
|
// Parse off the title
|
|
if (articleTitle == nil || articleTitle.vna_isBlank) {
|
|
articleTitle = [NSString vna_stringByRemovingHTML:articleBody].vna_firstNonBlankLine;
|
|
}
|
|
|
|
// Dates are stored as time intervals
|
|
NSTimeInterval interval = articleDate.timeIntervalSince1970;
|
|
|
|
// The article is revised if either the title or the body has changed.
|
|
|
|
NSString * existingTitle = existingArticle.title;
|
|
BOOL isArticleRevised = ![existingTitle isEqualToString:articleTitle];
|
|
if (!isArticleRevised) {
|
|
__block NSString * existingBody = existingArticle.body;
|
|
// the article text may not have been loaded yet, for instance if the folder is not displayed
|
|
if (existingBody == nil) {
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
FMResultSet * results = [db executeQuery:@"SELECT text FROM messages WHERE folder_id=? AND message_id=?",
|
|
@(folderID), articleGuid];
|
|
if ([results next]) {
|
|
existingBody = [results stringForColumn:@"text"];
|
|
} else {
|
|
existingBody = @"";
|
|
}
|
|
[results close];
|
|
}];
|
|
}
|
|
isArticleRevised = ![existingBody isEqualToString:articleBody];
|
|
}
|
|
|
|
if (isArticleRevised) {
|
|
// Articles preexisting in database should be marked as revised.
|
|
// New articles created during the current refresh should not be marked as revised,
|
|
// even if there are multiple versions of the new article in the feed.
|
|
if (existingArticle.revised || (existingArticle.status == ArticleStatusEmpty)) {
|
|
revised_flag = YES;
|
|
}
|
|
|
|
__block BOOL success;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
success = [db executeUpdate:@"UPDATE messages SET parent_id=?, sender=?, link=?, date=?, "
|
|
@"read_flag=0, title=?, text=?, revised_flag=? WHERE folder_id=? AND message_id=?",
|
|
@(parentId),
|
|
userName,
|
|
articleLink,
|
|
@(interval),
|
|
articleTitle,
|
|
articleBody,
|
|
@(revised_flag),
|
|
@(folderID),
|
|
articleGuid];
|
|
|
|
}];
|
|
|
|
if (!success) {
|
|
return NO;
|
|
} else {
|
|
// update the existing article in memory
|
|
existingArticle.title = articleTitle;
|
|
existingArticle.body = articleBody;
|
|
[existingArticle markRevised:revised_flag];
|
|
existingArticle.parentId = parentId;
|
|
existingArticle.author = userName;
|
|
existingArticle.link = articleLink;
|
|
return YES;
|
|
}
|
|
} else {
|
|
return NO;
|
|
}
|
|
}
|
|
|
|
/* purgeArticlesOlderThanTag
|
|
* Deletes all non-flagged articles from the messages list that are older than the
|
|
* specification. Cf. comments about autoExpireDuration in Preferences.m :
|
|
* A zero value disables auto-expire.
|
|
* Increments of 1000 specify months, so 1000 = 1 month, 1001 = 1 month and 1 day
|
|
*/
|
|
-(void)purgeArticlesOlderThanTag:(NSUInteger)tag
|
|
{
|
|
if (tag > 0) {
|
|
NSUInteger months = tag / 1000;
|
|
NSUInteger days = tag%1000;
|
|
NSDate *date = [[NSCalendar currentCalendar] dateByAddingUnit:NSCalendarUnitMonth
|
|
value:-months
|
|
toDate:[NSDate date]
|
|
options:0];
|
|
date = [[NSCalendar currentCalendar] dateByAddingUnit:NSCalendarUnitDay
|
|
value:-days
|
|
toDate:date
|
|
options:0];
|
|
NSTimeInterval timeDiff = date.timeIntervalSince1970;
|
|
|
|
[self.databaseQueue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"UPDATE messages SET deleted_flag=1 WHERE deleted_flag=0 AND marked_flag=0 AND read_flag=1 AND date < ?", @(timeDiff)];
|
|
}];
|
|
}
|
|
}
|
|
|
|
/* purgeDeletedArticles
|
|
* Remove from the database all articles which have the deleted_flag field set to YES. This
|
|
* also requires that we remove the same articles from all folder caches.
|
|
*/
|
|
-(void)purgeDeletedArticles
|
|
{
|
|
__block BOOL success;
|
|
[self.databaseQueue inDatabase:^(FMDatabase *db) {
|
|
success = [db executeUpdate:@"DELETE FROM messages WHERE deleted_flag=1"];
|
|
}];
|
|
|
|
if (success) {
|
|
for (Folder * folder in [self.foldersDict objectEnumerator])
|
|
[folder clearCache];
|
|
|
|
[[NSNotificationCenter defaultCenter] vna_postNotificationOnMainThreadWithName:MA_Notify_FoldersUpdated object:@(self.trashFolderId)];
|
|
}
|
|
}
|
|
|
|
/* deleteArticle
|
|
* Permanently deletes a article from the specified folder
|
|
*/
|
|
-(BOOL)deleteArticle:(Article *)article
|
|
{
|
|
NSInteger folderId = article.folderId;
|
|
NSString * guid = article.guid;
|
|
Folder * folder = [self folderFromID:folderId];
|
|
if (folder != nil) {
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
__block BOOL success;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
success = [db executeUpdate:@"DELETE FROM messages WHERE folder_id=? AND message_id=?",
|
|
@(folderId), guid];
|
|
}];
|
|
|
|
if (success) {
|
|
if (!article.read) {
|
|
[self setFolderUnreadCount:folder adjustment:-1];
|
|
}
|
|
if (folder.countOfCachedArticles > 0) {
|
|
// If we're in a smart folder, the cached article may be different.
|
|
Article * cachedArticle = [folder articleFromGuid:guid];
|
|
[cachedArticle markDeleted:YES];
|
|
[folder removeArticleFromCache:guid];
|
|
}
|
|
return YES;
|
|
}
|
|
}
|
|
return NO;
|
|
}
|
|
|
|
/* initSmartfoldersDict
|
|
* Preloads all the smart folders into the smartfoldersDict dictionary.
|
|
*/
|
|
-(void)initSmartfoldersDict
|
|
{
|
|
if (!self.initializedSmartfoldersDict) {
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
// Make sure we have a database queue.
|
|
NSAssert(queue, @"Database queue not assigned for this item");
|
|
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
FMResultSet * results = [db executeQuery:@"SELECT folder_id, search_string FROM smart_folders"];
|
|
while ([results next]) {
|
|
NSInteger folderId = [results stringForColumnIndex:0].integerValue;
|
|
NSString * search_string = [results stringForColumnIndex:1];
|
|
|
|
CriteriaTree * criteriaTree = [[CriteriaTree alloc] initWithString:search_string];
|
|
self.smartfoldersDict[@(folderId)] = criteriaTree;
|
|
}
|
|
[results close];
|
|
}];
|
|
self.initializedSmartfoldersDict = YES;
|
|
}
|
|
}
|
|
|
|
/* searchStringForSmartFolder
|
|
* Retrieve the smart folder criteria string for the specified folderId. Returns nil if
|
|
* folderId is not a smart folder.
|
|
*/
|
|
-(CriteriaTree *)searchStringForSmartFolder:(NSInteger)folderId
|
|
{
|
|
[self initSmartfoldersDict];
|
|
return self.smartfoldersDict[@(folderId)];
|
|
}
|
|
|
|
/* addSmartFolder
|
|
* Create a new smart folder. If the specified folder already exists, then this is synonymous to
|
|
* calling updateSearchFolder.
|
|
*/
|
|
-(NSInteger)addSmartFolder:(NSString *)folderName underParent:(NSInteger)parentId withQuery:(CriteriaTree *)criteriaTree
|
|
{
|
|
NSInteger folderId = [self addFolder:parentId afterChild:0 folderName:folderName type:VNAFolderTypeSmart canAppendIndex:NO];
|
|
if (folderId != -1) {
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"INSERT INTO smart_folders (folder_id, search_string) VALUES (?, ?)",
|
|
@(folderId),
|
|
criteriaTree.string];
|
|
}];
|
|
|
|
self.smartfoldersDict[@(folderId)] = criteriaTree;
|
|
}
|
|
return folderId;
|
|
}
|
|
|
|
/* updateSearchFolder
|
|
* Updates the search string for the specified folder.
|
|
*/
|
|
-(void)updateSearchFolder:(NSInteger)folderId withFolder:(NSString *)folderName withQuery:(CriteriaTree *)criteriaTree
|
|
{
|
|
Folder * folder = [self folderFromID:folderId];
|
|
if (![folder.name isEqualToString:folderName]) {
|
|
[self setName:folderName forFolder:folderId];
|
|
}
|
|
|
|
// Update the smart folder string
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"UPDATE smart_folders SET search_string=? WHERE folder_id=?",
|
|
criteriaTree.string,
|
|
@(folderId)];
|
|
}];
|
|
|
|
self.smartfoldersDict[@(folderId)] = criteriaTree;
|
|
|
|
NSNotificationCenter * nc = [NSNotificationCenter defaultCenter];
|
|
[nc vna_postNotificationOnMainThreadWithName:MA_Notify_ArticleListContentChange
|
|
object:@(folderId)];
|
|
}
|
|
|
|
/* initFolderArray
|
|
* Initializes the folder array if necessary.
|
|
*/
|
|
-(void)initFolderArray
|
|
{
|
|
if (!self.initializedfoldersDict) {
|
|
// Make sure we have a database.
|
|
NSAssert(self.databaseQueue, @"Database not assigned for this item");
|
|
|
|
// Keep running count of total unread articles
|
|
_countOfUnread = 0;
|
|
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
|
|
[queue inTransaction:^(FMDatabase *db, BOOL *rollback) {
|
|
FMResultSet * results = [db executeQuery:@"SELECT folder_id, parent_id, foldername, unread_count, last_update,"
|
|
@" type, flags, next_sibling, first_child FROM folders ORDER BY folder_id"];
|
|
if (!results) {
|
|
NSLog(@"%s: executeQuery error: %@", __FUNCTION__, [db lastErrorMessage]);
|
|
return;
|
|
}
|
|
|
|
while ([results next]) {
|
|
NSInteger newItemId = [results stringForColumnIndex:0].integerValue;
|
|
NSInteger newParentId = [results stringForColumnIndex:1].integerValue;
|
|
NSString * name = [results stringForColumnIndex:2];
|
|
if (name == nil) { // Paranoid check because of https://github.com/ViennaRSS/vienna-rss/issues/877
|
|
name = [Database untitledFeedFolderName];
|
|
}
|
|
NSInteger unreadCount = [results stringForColumnIndex:3].integerValue;
|
|
NSDate * lastUpdate = [NSDate dateWithTimeIntervalSince1970:[results stringForColumnIndex:4].doubleValue];
|
|
NSInteger type = [results stringForColumnIndex:5].integerValue;
|
|
NSInteger flags = [results stringForColumnIndex:6].integerValue;
|
|
NSInteger nextSibling = [results stringForColumnIndex:7].integerValue;
|
|
NSInteger firstChild = [results stringForColumnIndex:8].integerValue;
|
|
|
|
Folder * folder = [[Folder alloc] initWithId:newItemId parentId:newParentId name:name type:type];
|
|
folder.nextSiblingId = nextSibling;
|
|
folder.firstChildId = firstChild;
|
|
if (folder.type != VNAFolderTypeRSS && folder.type != VNAFolderTypeOpenReader) {
|
|
unreadCount = 0;
|
|
}
|
|
folder.unreadCount = unreadCount;
|
|
folder.lastUpdate = lastUpdate;
|
|
[folder setFlag:flags];
|
|
if (unreadCount > 0) {
|
|
self->_countOfUnread += unreadCount;
|
|
}
|
|
self.foldersDict[@(newItemId)] = folder;
|
|
|
|
// Remember the trash folder
|
|
if (folder.type == VNAFolderTypeTrash) {
|
|
self.trashFolder = folder;
|
|
}
|
|
|
|
// Remember the search folder
|
|
if (folder.type == VNAFolderTypeSearch) {
|
|
self.searchFolder = folder;
|
|
}
|
|
}
|
|
[results close];
|
|
|
|
// Load all RSS folders and add them to the list.
|
|
results = [db executeQuery:@"SELECT folder_id, feed_url, username, last_update_string, description, home_page, bloglines_id FROM rss_folders"];
|
|
while ([results next]) {
|
|
NSInteger folderId = [results stringForColumnIndex:0].integerValue;
|
|
NSString * url = [results stringForColumnIndex:1];
|
|
NSString * username = [results stringForColumnIndex:2];
|
|
NSString * lastUpdateString = [results stringForColumnIndex:3];
|
|
NSString * descriptiontext = [results stringForColumnIndex:4];
|
|
NSString * linktext = [results stringForColumnIndex:5];
|
|
NSString * remoteId = [results stringForColumnIndex:6];
|
|
|
|
Folder * folder = [self folderFromID:folderId];
|
|
folder.feedDescription = descriptiontext;
|
|
folder.homePage = linktext;
|
|
folder.feedURL = url;
|
|
folder.lastUpdateString = lastUpdateString;
|
|
folder.username = username;
|
|
folder.remoteId = remoteId;
|
|
}
|
|
[results close];
|
|
}];
|
|
// Fix the childUnreadCount for every parent
|
|
for (Folder * folder in [self.foldersDict objectEnumerator]) {
|
|
if (folder.unreadCount > 0 && folder.parentId != VNAFolderTypeRoot) {
|
|
Folder * parentFolder = [self folderFromID:folder.parentId];
|
|
while (parentFolder != nil) {
|
|
parentFolder.childUnreadCount = parentFolder.childUnreadCount + folder.unreadCount;
|
|
parentFolder = [self folderFromID:parentFolder.parentId];
|
|
}
|
|
}
|
|
}
|
|
// Done
|
|
self.initializedfoldersDict = YES;
|
|
}
|
|
}
|
|
|
|
/* arrayOfFolders
|
|
* Returns an NSArray of all folders with the specified parent. It does not include the
|
|
* parent folder nor does it include any folders within groups under that parent. Specifically
|
|
* it is a single level search and is actually slightly faster than arrayofSubFolders for
|
|
* callers that require this distinction.
|
|
*/
|
|
-(NSArray *)arrayOfFolders:(NSInteger)parentId
|
|
{
|
|
// Prime the cache
|
|
if (self.initializedfoldersDict == NO) {
|
|
[self initFolderArray];
|
|
}
|
|
|
|
NSMutableArray * newArray = [NSMutableArray array];
|
|
if (newArray != nil) {
|
|
for (Folder * folder in [self.foldersDict objectEnumerator]) {
|
|
if (folder.parentId == parentId) {
|
|
[newArray addObject:folder];
|
|
}
|
|
}
|
|
}
|
|
return [newArray copy];
|
|
}
|
|
|
|
/* arrayOfSubFolders
|
|
* Returns an NSArray of all folders from the specified folder down.
|
|
*/
|
|
-(NSArray *)arrayOfSubFolders:(Folder *)folder {
|
|
NSMutableArray * newArray = [NSMutableArray arrayWithObject:folder];
|
|
if (newArray != nil) {
|
|
NSInteger parentId = folder.itemId;
|
|
|
|
for (Folder * item in [self.foldersDict objectEnumerator]) {
|
|
if (item.parentId == parentId) {
|
|
if (item.type == VNAFolderTypeGroup) {
|
|
[newArray addObjectsFromArray:[self arrayOfSubFolders:item]];
|
|
} else {
|
|
[newArray addObject:item];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return [newArray sortedArrayUsingSelector:@selector(folderIDCompare:)];
|
|
}
|
|
|
|
/* arrayOfAllFolders
|
|
* Returns an unsorted array of all regular folders (RSS folders and group folders).
|
|
*/
|
|
-(NSArray *)arrayOfAllFolders
|
|
{
|
|
// Prime the cache
|
|
if (self.initializedfoldersDict == NO) {
|
|
[self initFolderArray];
|
|
}
|
|
|
|
return self.foldersDict.allValues;
|
|
}
|
|
|
|
/* minimalCacheForFolder
|
|
* Returns a minimal cache of article information for the specified folder,
|
|
* which is enough for tasks like feed refreshes, but is not complete enough
|
|
* for displaying articles : it lacks articles' descriptions and dates.
|
|
*/
|
|
-(NSArray *)minimalCacheForFolder:(NSInteger)folderId
|
|
{
|
|
// Prime the folder cache
|
|
[self initFolderArray];
|
|
|
|
__block NSInteger unread_count = 0;
|
|
NSMutableArray * myCache = [NSMutableArray array];
|
|
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
FMResultSet * results = [db executeQueryWithFormat:@"SELECT message_id, read_flag, marked_flag, deleted_flag, title, link, revised_flag, hasenclosure_flag, enclosure FROM messages WHERE folder_id=%ld", (long)folderId];
|
|
while ([results next]) {
|
|
NSString * guid = [results stringForColumnIndex:0];
|
|
BOOL read_flag = [results stringForColumnIndex:1].integerValue;
|
|
BOOL marked_flag = [results stringForColumnIndex:2].integerValue;
|
|
BOOL deleted_flag = [results stringForColumnIndex:3].integerValue;
|
|
NSString * title = [results stringForColumnIndex:4];
|
|
NSString * link = [results stringForColumnIndex:5];
|
|
BOOL revised_flag = [results stringForColumnIndex:6].integerValue;
|
|
BOOL hasenclosure_flag = [results stringForColumnIndex:7].integerValue;
|
|
NSString * enclosure = [results stringForColumnIndex:8];
|
|
|
|
// Keep our own track of unread articles
|
|
if (!read_flag) {
|
|
++unread_count;
|
|
}
|
|
|
|
Article * article = [[Article alloc] initWithGuid:guid];
|
|
[article markRead:read_flag];
|
|
[article markFlagged:marked_flag];
|
|
[article markRevised:revised_flag];
|
|
[article markDeleted:deleted_flag];
|
|
article.folderId = folderId;
|
|
article.title = title;
|
|
article.link = link;
|
|
article.enclosure = enclosure;
|
|
article.hasEnclosure = hasenclosure_flag;
|
|
[myCache addObject:article];
|
|
}
|
|
[results close];
|
|
}];
|
|
|
|
// This is a good time to do a quick check to ensure that our
|
|
// own count of unread is in sync with the folders count and fix
|
|
// them if not.
|
|
Folder * folder = [self folderFromID:folderId];
|
|
if (unread_count != folder.unreadCount) {
|
|
NSLog(@"Fixing unread count for %@ (%ld on folder versus %ld in articles)", folder.name, (long)folder.unreadCount, (long)unread_count);
|
|
NSInteger diff = (unread_count - folder.unreadCount);
|
|
[self setFolderUnreadCount:folder adjustment:diff];
|
|
[folder setNonPersistedFlag:VNAFolderFlagBuggySync];
|
|
}
|
|
|
|
return [myCache copy];
|
|
}
|
|
|
|
/* sqlScopeForFolder
|
|
* Create a SQL 'where' clause that scopes to either the individual folder or the folder and
|
|
* all sub-folders.
|
|
*/
|
|
-(NSString *)sqlScopeForFolder:(Folder *)folder flags:(VNAQueryScope)scopeFlags field:(NSString *)field
|
|
{
|
|
NSString * operatorString = (scopeFlags & VNAQueryScopeInclusive) ? @"=" : @"<>";
|
|
NSString * conditionString = (scopeFlags & VNAQueryScopeInclusive) ? @" or " : @" and ";
|
|
BOOL subScope = (scopeFlags & VNAQueryScopeSubFolders) ? YES : NO; // Avoid problems casting into BOOL.
|
|
NSInteger folderId;
|
|
|
|
// If folder is nil, rather than report an error, default to some impossible value
|
|
if (folder != nil) {
|
|
folderId = folder.itemId;
|
|
} else {
|
|
subScope = NO;
|
|
folderId = 0;
|
|
}
|
|
|
|
// Group folders must always have subscope
|
|
if (folder && folder.type == VNAFolderTypeGroup) {
|
|
subScope = YES;
|
|
}
|
|
|
|
// Straightforward folder is <something>
|
|
if (!subScope) {
|
|
return [NSString stringWithFormat:@"%@%@%ld", field, operatorString, (long)folderId];
|
|
}
|
|
// For under/not-under operators, we're creating a SQL statement of the format
|
|
// (folder_id = <value1> || folder_id = <value2>...). It is possible to try and simplify
|
|
// the string by looking for ranges but I suspect that given the spread of IDs this may
|
|
// well be false optimisation.
|
|
//
|
|
NSArray * childFolders = [self arrayOfSubFolders:folder];
|
|
NSMutableString * sqlString = [[NSMutableString alloc] init];
|
|
NSInteger count = childFolders.count;
|
|
NSInteger index;
|
|
|
|
if (count > 1) {
|
|
[sqlString appendString:@"("];
|
|
}
|
|
for (index = 0; index < count; ++index) {
|
|
Folder * folder = childFolders[index];
|
|
if (index > 0) {
|
|
[sqlString appendString:conditionString];
|
|
}
|
|
[sqlString appendFormat:@"%@%@%ld", field, operatorString, (long)folder.itemId];
|
|
}
|
|
if (count > 1) {
|
|
[sqlString appendString:@")"];
|
|
}
|
|
return sqlString;
|
|
}
|
|
|
|
/* criteriaForFolder
|
|
* Returns the CriteriaTree that will return the folder contents.
|
|
* TODO: put this in Criteria+SQL.swift or some other swift file to free VNACriteriaOperator enum from Objective-C legacy
|
|
*/
|
|
-(CriteriaTree *)criteriaForFolder:(NSInteger)folderId
|
|
{
|
|
Folder * folder = [self folderFromID:folderId];
|
|
if (folder == nil) {
|
|
return nil;
|
|
}
|
|
|
|
if (folder.type == VNAFolderTypeSearch) {
|
|
CriteriaTree *tree = [CriteriaTree new];
|
|
Criteria *clause = [[Criteria alloc] initWithField:MA_Field_Text
|
|
operatorType:VNACriteriaOperatorContains
|
|
value:self.searchString];
|
|
tree.criteriaTree = [tree.criteriaTree arrayByAddingObject:(id<CriteriaElement>)clause];
|
|
return tree;
|
|
}
|
|
|
|
if (folder.type == VNAFolderTypeTrash) {
|
|
CriteriaTree * tree = [[CriteriaTree alloc] init];
|
|
Criteria * clause = [[Criteria alloc] initWithField:MA_Field_Deleted operatorType:VNACriteriaOperatorEqualTo value:@"Yes"];
|
|
tree.criteriaTree = [tree.criteriaTree arrayByAddingObject:(id<CriteriaElement>)clause];
|
|
return tree;
|
|
}
|
|
|
|
if (folder.type == VNAFolderTypeSmart) {
|
|
[self initSmartfoldersDict];
|
|
return self.smartfoldersDict[@(folderId)];
|
|
}
|
|
|
|
CriteriaTree * tree = [[CriteriaTree alloc] init];
|
|
Criteria * clause = [[Criteria alloc] initWithField:MA_Field_Folder operatorType:VNACriteriaOperatorUnder value:folder.name];
|
|
tree.criteriaTree = [tree.criteriaTree arrayByAddingObject:(id<CriteriaElement>)clause];
|
|
return tree;
|
|
}
|
|
|
|
/* arrayOfUnreadArticlesRefs
|
|
* Retrieves an array of ArticleReference objects that represent all unread
|
|
* articles in the specified folder.
|
|
* Note : when possible, you should use the interface provided by the Folder class instead of this
|
|
*/
|
|
-(NSArray *)arrayOfUnreadArticlesRefs:(NSInteger)folderId
|
|
{
|
|
Folder * folder = [self folderFromID:folderId];
|
|
if (folder != nil) {
|
|
NSMutableArray * newArray = [NSMutableArray arrayWithCapacity:folder.unreadCount];
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
FMResultSet * results = [db executeQuery:@"SELECT message_id FROM messages WHERE folder_id=? AND read_flag=0", @(folderId)];
|
|
while ([results next]) {
|
|
NSString * guid = [results stringForColumnIndex:0];
|
|
[newArray addObject:[ArticleReference makeReferenceFromGUID:guid inFolder:folderId]];
|
|
}
|
|
[results close];
|
|
}];
|
|
return [newArray copy];
|
|
} else {
|
|
return nil;
|
|
}
|
|
}
|
|
|
|
/* arrayOfArticles
|
|
* Retrieves an array containing all articles (including text) for the
|
|
* specified folder. If folderId is zero, all folders are searched. The
|
|
* filterString option constrains the array to all those articles that
|
|
* contain the specified filter.
|
|
*/
|
|
-(NSArray *)arrayOfArticles:(NSInteger)folderId filterString:(NSString *)filterString
|
|
{
|
|
NSMutableArray * newArray = [NSMutableArray array];
|
|
NSString * filterClause = @"";
|
|
__weak NSString * queryString;
|
|
Folder * folder = nil;
|
|
__block NSInteger unread_count = 0;
|
|
|
|
queryString=@"SELECT message_id, folder_id, parent_id, read_flag, marked_flag, deleted_flag, title, sender,"
|
|
@" link, createddate, date, text, revised_flag, hasenclosure_flag, enclosure FROM messages";
|
|
|
|
// If folderId is zero then we're searching the entire database
|
|
// otherwise we need to construct a criteria tree for this folder
|
|
if (folderId != 0) {
|
|
folder = [self folderFromID:folderId];
|
|
if (folder == nil) {
|
|
return nil;
|
|
}
|
|
CriteriaTree * tree = [self criteriaForFolder:folderId];
|
|
queryString = [NSString stringWithFormat:@"%@ WHERE (%@)", queryString, [tree toSQLForDatabase:self]];
|
|
}
|
|
|
|
// prepare filter if needed
|
|
if ([filterString isNotEqualTo:@""]) {
|
|
if (folderId == 0) {
|
|
filterClause = @"WHERE (title LIKE '%' || ? || '%' OR text LIKE '%' || ? || '%')";
|
|
} else {
|
|
filterClause = @"AND (title LIKE '%' || ? || '%' OR text LIKE '%' || ? || '%')";
|
|
}
|
|
queryString = [NSString stringWithFormat:@"%@ %@", queryString, filterClause];
|
|
};
|
|
|
|
// Time to run the query
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
FMResultSet * results;
|
|
if ([filterString isEqualTo:@""]) {
|
|
results = [db executeQuery:queryString];
|
|
} else {
|
|
results = [db executeQuery:queryString, filterString, filterString];
|
|
}
|
|
while ([results next]) {
|
|
Article * article = [[Article alloc] initWithGuid:[results stringForColumnIndex:0]];
|
|
article.folderId = [results intForColumnIndex:1];
|
|
article.parentId = [results intForColumnIndex:2];
|
|
[article markRead:[results intForColumnIndex:3]];
|
|
[article markFlagged:[results intForColumnIndex:4]];
|
|
[article markDeleted:[results intForColumnIndex:5]];
|
|
article.title = [results stringForColumnIndex:6];
|
|
article.author = [results stringForColumnIndex:7];
|
|
article.link = [results stringForColumnIndex:8];
|
|
article.createdDate = [NSDate dateWithTimeIntervalSince1970:[results stringForColumnIndex:9].doubleValue];
|
|
article.date = [NSDate dateWithTimeIntervalSince1970:[results stringForColumnIndex:10].doubleValue];
|
|
NSString * text = [results stringForColumnIndex:11];
|
|
article.body = text;
|
|
[article markRevised:[results intForColumnIndex:12]];
|
|
article.hasEnclosure = [results intForColumnIndex:13];
|
|
article.enclosure = [results stringForColumnIndex:14];
|
|
|
|
if (folder == nil || !article.deleted || folder.type == VNAFolderTypeTrash) {
|
|
[newArray addObject:article];
|
|
}
|
|
|
|
// Keep our own track of unread articles
|
|
if (!article.read) {
|
|
++unread_count;
|
|
}
|
|
|
|
}
|
|
[results close];
|
|
}];
|
|
|
|
// This is a good time to do a quick check to ensure that our
|
|
// own count of unread is in sync with the folders count and fix
|
|
// them if not.
|
|
if (folder && [filterString isEqualTo:@""] && (folder.type == VNAFolderTypeRSS || folder.type == VNAFolderTypeOpenReader)) {
|
|
if (unread_count != folder.unreadCount) {
|
|
NSLog(@"Fixing unread count for %@ (%ld on folder versus %ld in articles)", folder.name, (long)folder.unreadCount, (long)unread_count);
|
|
NSInteger diff = (unread_count - folder.unreadCount);
|
|
[self setFolderUnreadCount:folder adjustment:diff];
|
|
[folder setNonPersistedFlag:VNAFolderFlagBuggySync];
|
|
}
|
|
}
|
|
|
|
return [newArray copy];
|
|
}
|
|
|
|
/* markFolderRead
|
|
* Mark all articles in the folder and sub-folders read. This should be called
|
|
* within a transaction since it is SQL intensive.
|
|
*/
|
|
-(BOOL)markFolderRead:(NSInteger)folderId
|
|
{
|
|
Folder * folder;
|
|
BOOL result = NO;
|
|
|
|
// Recurse and mark child folders read too
|
|
for (folder in [self arrayOfFolders:folderId]) {
|
|
if ([self markFolderRead:folder.itemId]) {
|
|
result = YES;
|
|
}
|
|
}
|
|
|
|
folder = [self folderFromID:folderId];
|
|
if (folder != nil && folder.unreadCount > 0) {
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
__block BOOL success;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
success = [db executeUpdate:@"UPDATE messages SET read_flag=1 WHERE folder_id=? AND read_flag=0",
|
|
@(folderId)];
|
|
}];
|
|
|
|
if (success) {
|
|
if (folder.countOfCachedArticles > 0) {
|
|
// update the existing cache of articles and update the unread count
|
|
[folder markArticlesInCacheRead];
|
|
}
|
|
// set the unread count to 0
|
|
[self setFolderUnreadCount:folder adjustment:-folder.unreadCount];
|
|
result = YES;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/* markArticleRead
|
|
* Marks a article as read or unread.
|
|
*/
|
|
-(void)markArticleRead:(NSInteger)folderId guid:(NSString *)guid isRead:(BOOL)isRead
|
|
{
|
|
Folder * folder = [self folderFromID:folderId];
|
|
if (folder != nil) {
|
|
Article * article = [folder articleFromGuid:guid];
|
|
if (article != nil && isRead != article.read) {
|
|
// Mark an individual article read
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
__block BOOL success;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
success = [db executeUpdate:@"UPDATE messages SET read_flag=? WHERE folder_id=? AND message_id=?",
|
|
@(isRead), @(folderId), guid];
|
|
}];
|
|
if (success) {
|
|
NSInteger adjustment = (isRead ? -1 : 1);
|
|
|
|
[article markRead:isRead];
|
|
[self setFolderUnreadCount:folder adjustment:adjustment];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* markUnreadArticlesFromFolder
|
|
* Marks as unread a set of articles.
|
|
*/
|
|
-(void)markUnreadArticlesFromFolder:(Folder *)folder guidArray:(NSArray *)guidArray
|
|
{
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
NSInteger folderId = folder.itemId;
|
|
if (guidArray.count>0) {
|
|
NSString * guidList = [guidArray componentsJoinedByString:@"','"];
|
|
[queue inTransaction:^(FMDatabase *db, BOOL *rollback) {
|
|
NSString * statement1 = [NSString stringWithFormat:@"UPDATE messages SET read_flag=1 WHERE folder_id=%ld AND read_flag=0 AND message_id NOT IN ('%@')", (long)folderId, guidList];
|
|
[db executeUpdate:statement1];
|
|
NSString * statement2 = [NSString stringWithFormat:@"UPDATE messages SET read_flag=0 WHERE folder_id=%ld AND read_flag=1 AND message_id IN ('%@')", (long)folderId, guidList];
|
|
[db executeUpdate:statement2];
|
|
}];
|
|
} else {
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"UPDATE messages SET read_flag=1 WHERE folder_id=? AND read_flag=0", @(folderId)];
|
|
}];
|
|
}
|
|
NSInteger adjustment = guidArray.count-folder.unreadCount;
|
|
[self setFolderUnreadCount:folder adjustment:adjustment];
|
|
}
|
|
|
|
/* markStarredArticlesFromFolder
|
|
* Marks starred a set of articles.
|
|
*/
|
|
-(void)markStarredArticlesFromFolder:(Folder *)folder guidArray:(NSArray *)guidArray
|
|
{
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
NSInteger folderId = folder.itemId;
|
|
if (guidArray.count>0) {
|
|
NSString * guidList = [guidArray componentsJoinedByString:@"','"];
|
|
[queue inTransaction:^(FMDatabase *db, BOOL *rollback) {
|
|
NSString * statement1 = [NSString stringWithFormat:@"UPDATE messages SET marked_flag=1 WHERE folder_id=%ld AND marked_flag=0 AND message_id IN ('%@')", (long)folderId, guidList];
|
|
[db executeUpdate:statement1];
|
|
NSString * statement2 = [NSString stringWithFormat:@"UPDATE messages SET marked_flag=0 WHERE folder_id=%ld AND marked_flag=1 AND message_id NOT IN ('%@')", (long)folderId, guidList];
|
|
[db executeUpdate:statement2];
|
|
}];
|
|
} else {
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"UPDATE messages SET marked_flag=0 WHERE folder_id=? AND marked_flag=1", @(folderId)];
|
|
}];
|
|
}
|
|
}
|
|
|
|
/* setFolderUnreadCount
|
|
* Adjusts the unread count on the specified folder by the given delta. The same delta is
|
|
* also applied to the childUnreadCount of all ancestor folders.
|
|
*/
|
|
-(void)setFolderUnreadCount:(Folder *)folder adjustment:(NSUInteger)adjustment
|
|
{
|
|
_countOfUnread += adjustment;
|
|
NSInteger newCount = folder.unreadCount + adjustment;
|
|
folder.unreadCount = newCount;
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"UPDATE folders SET unread_count=? WHERE folder_id=?", @(newCount), @(folder.itemId)];
|
|
[[NSNotificationCenter defaultCenter] vna_postNotificationOnMainThreadWithName:MA_Notify_FoldersUpdated object:@(folder.itemId)];
|
|
}];
|
|
|
|
// Update childUnreadCount for parents.
|
|
Folder * tmpFolder = [self folderFromID:folder.parentId];
|
|
while (tmpFolder != nil) {
|
|
tmpFolder.childUnreadCount = tmpFolder.childUnreadCount + adjustment;
|
|
tmpFolder = [self folderFromID:tmpFolder.parentId];
|
|
}
|
|
}
|
|
|
|
/* markArticleFlagged
|
|
* Marks a article as flagged or unflagged.
|
|
*/
|
|
-(void)markArticleFlagged:(NSInteger)folderId guid:(NSString *)guid isFlagged:(BOOL)isFlagged
|
|
{
|
|
Folder * folder = [self folderFromID:folderId];
|
|
if (folder != nil) {
|
|
Article * article = [folder articleFromGuid:guid];
|
|
if (article != nil && isFlagged != article.flagged) {
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
__block BOOL success;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
success = [db executeUpdate:@"UPDATE messages SET marked_flag=? WHERE folder_id=? AND message_id=?",
|
|
@(isFlagged),
|
|
@(folderId),
|
|
guid];
|
|
}];
|
|
|
|
if (success) {
|
|
// Mark an individual article flagged
|
|
[article markFlagged:isFlagged];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* markArticleDeleted
|
|
* Marks an article as deleted. Deleted articles should have been marked read first.
|
|
*/
|
|
-(void)markArticleDeleted:(Article *)article isDeleted:(BOOL)isDeleted
|
|
{
|
|
NSInteger folderId = article.folderId;
|
|
NSString * guid = article.guid;
|
|
Folder * folder = [self folderFromID:folderId];
|
|
if (folder !=nil) {
|
|
if (isDeleted && !article.read) {
|
|
[self markArticleRead:folderId guid:guid isRead:YES];
|
|
}
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
[db executeUpdate:@"UPDATE messages SET deleted_flag=? WHERE folder_id=? AND message_id=?",
|
|
@(isDeleted),
|
|
@(folderId),
|
|
guid];
|
|
}];
|
|
if (isDeleted && !article.deleted) {
|
|
[article markDeleted:YES];
|
|
if (folder.countOfCachedArticles > 0) {
|
|
// If we're in a smart folder, the cached article may be different.
|
|
Article * cachedArticle = [folder articleFromGuid:guid];
|
|
[cachedArticle markDeleted:YES];
|
|
[folder removeArticleFromCache:guid];
|
|
}
|
|
} else if (!isDeleted) {
|
|
// if we undelete, allow the RSS or OpenReader folder
|
|
// to get the restored article
|
|
[folder restoreArticleToCache:article];
|
|
[article markDeleted:NO];
|
|
}
|
|
}
|
|
}
|
|
|
|
/* isTrashEmpty
|
|
* Returns YES if there are no deleted articles, NO if there are deleted articles
|
|
*/
|
|
-(BOOL)isTrashEmpty
|
|
{
|
|
__block BOOL result;
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
FMResultSet * results = [db executeQuery:@"SELECT deleted_flag FROM messages WHERE deleted_flag=1"];
|
|
if ([results next]) {
|
|
result= NO;
|
|
} else {
|
|
result=YES;
|
|
}
|
|
[results close];
|
|
}];
|
|
|
|
return result;
|
|
}
|
|
|
|
/* guidHistoryForFolderId
|
|
* Returns an array of all article guids ever downloaded for the specified folder.
|
|
*/
|
|
-(NSArray *)guidHistoryForFolderId:(NSInteger)folderId
|
|
{
|
|
NSMutableArray * articleGuids = [NSMutableArray array];
|
|
FMDatabaseQueue *queue = self.databaseQueue;
|
|
[queue inDatabase:^(FMDatabase *db) {
|
|
FMResultSet * results = [db executeQuery:@"SELECT message_id FROM rss_guids WHERE folder_id=?", @(folderId)];
|
|
while ([results next]) {
|
|
NSString * guid = [results stringForColumn:@"message_id"];
|
|
if (guid != nil) {
|
|
[articleGuids addObject:guid];
|
|
}
|
|
}
|
|
[results close];
|
|
}];
|
|
|
|
return [articleGuids copy];
|
|
}
|
|
|
|
/*!
|
|
* Get the path to the database file
|
|
*
|
|
* @return A string representation of the database file's path
|
|
*/
|
|
+ (NSString *)databasePath {
|
|
// Fully expand the path and make sure it exists because if the
|
|
// database file itself doesn't exist, we want to create it and
|
|
// we can't create it on a non-existent path.
|
|
NSFileManager * fileManager = [NSFileManager defaultManager];
|
|
NSString * qualifiedDatabaseFileName = [[Preferences standardPreferences] defaultDatabase].stringByExpandingTildeInPath;
|
|
NSString * databaseFolder = qualifiedDatabaseFileName.stringByDeletingLastPathComponent;
|
|
BOOL isDir;
|
|
|
|
|
|
if (![fileManager fileExistsAtPath:databaseFolder isDirectory:&isDir]) {
|
|
NSError *error;
|
|
if (![fileManager createDirectoryAtPath:databaseFolder withIntermediateDirectories:YES attributes:NULL error:&error]) {
|
|
NSAlert *alert = [NSAlert alertWithError:error];
|
|
alert.alertStyle = NSAlertStyleCritical;
|
|
alert.messageText = NSLocalizedString(@"Sorry, but Vienna was unable to create the database folder", nil);
|
|
alert.informativeText = [NSString stringWithFormat:NSLocalizedString(@"Vienna was trying to create the folder \"%@\" but an error occurred. Check the permissions on the folders on the path specified.", nil), databaseFolder];
|
|
[alert runModal];
|
|
|
|
return nil;
|
|
}
|
|
}
|
|
|
|
return qualifiedDatabaseFileName;
|
|
}
|
|
|
|
|
|
/* close
|
|
* Close the database. All internal resources are released and a new,
|
|
* possibly different, database can be opened instead.
|
|
*/
|
|
-(void)close
|
|
{
|
|
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
|
[self.foldersDict removeAllObjects];
|
|
[self.smartfoldersDict removeAllObjects];
|
|
self.trashFolder = nil;
|
|
self.searchFolder = nil;
|
|
self.initializedfoldersDict = NO;
|
|
self.initializedSmartfoldersDict = NO;
|
|
_countOfUnread = 0;
|
|
[self.databaseQueue close];
|
|
}
|
|
|
|
/* dealloc
|
|
* Clean up and release resources.
|
|
*/
|
|
-(void)dealloc
|
|
{
|
|
[self close];
|
|
}
|
|
@end
|
|
|