Implement retention policy for feed-based accounts (local, iCloud).

This commit is contained in:
Brent Simmons 2020-03-29 23:20:01 -07:00
parent 85b24ff92d
commit cf98ff49ea
4 changed files with 147 additions and 40 deletions

View File

@ -677,56 +677,41 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
}
func update(_ webFeed: WebFeed, with parsedFeed: ParsedFeed, _ completion: @escaping DatabaseCompletionBlock) {
// Used only by an On My Mac account.
// Used only by an On My Mac or iCloud account.
precondition(Thread.isMainThread)
precondition(type == .onMyMac) // TODO: allow iCloud
webFeed.takeSettings(from: parsedFeed)
let webFeedIDsAndItems = [webFeed.webFeedID: parsedFeed.items]
update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: false, completion: completion)
let parsedItems = parsedFeed.items
guard !parsedItems.isEmpty else {
completion(nil)
return
}
database.update(with: parsedItems, webFeedID: webFeed.webFeedID) { updateArticlesResult in
switch updateArticlesResult {
case .success(let newAndUpdatedArticles):
self.sendNotificationAbout(newAndUpdatedArticles)
completion(nil)
case .failure(let databaseError):
completion(databaseError)
}
}
}
func update(webFeedIDsAndItems: [String: Set<ParsedItem>], defaultRead: Bool, completion: @escaping DatabaseCompletionBlock) {
// Used only by syncing systems.
precondition(Thread.isMainThread)
precondition(type != .onMyMac) // TODO: also make sure type != iCloud
guard !webFeedIDsAndItems.isEmpty else {
completion(nil)
return
}
database.update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: defaultRead) { updateArticlesResult in
func sendNotificationAbout(newArticles: Set<Article>?, updatedArticles: Set<Article>?) {
var webFeeds = Set<WebFeed>()
if let newArticles = newArticles {
webFeeds.formUnion(Set(newArticles.compactMap { $0.webFeed }))
}
if let updatedArticles = updatedArticles {
webFeeds.formUnion(Set(updatedArticles.compactMap { $0.webFeed }))
}
var shouldSendNotification = false
var userInfo = [String: Any]()
if let newArticles = newArticles, !newArticles.isEmpty {
shouldSendNotification = true
userInfo[UserInfoKey.newArticles] = newArticles
self.updateUnreadCounts(for: webFeeds) {
NotificationCenter.default.post(name: .DownloadArticlesDidUpdateUnreadCounts, object: self, userInfo: nil)
}
}
if let updatedArticles = updatedArticles, !updatedArticles.isEmpty {
shouldSendNotification = true
userInfo[UserInfoKey.updatedArticles] = updatedArticles
}
if shouldSendNotification {
userInfo[UserInfoKey.webFeeds] = webFeeds
NotificationCenter.default.post(name: .AccountDidDownloadArticles, object: self, userInfo: userInfo)
}
}
switch updateArticlesResult {
case .success(let newAndUpdatedArticles):
sendNotificationAbout(newArticles: newAndUpdatedArticles.newArticles, updatedArticles: newAndUpdatedArticles.updatedArticles)
self.sendNotificationAbout(newAndUpdatedArticles)
completion(nil)
case .failure(let databaseError):
completion(databaseError)
@ -1246,6 +1231,38 @@ private extension Account {
feed.unreadCount = unreadCount
}
}
func sendNotificationAbout(_ newAndUpdatedArticles: NewAndUpdatedArticles) {
var webFeeds = Set<WebFeed>()
if let newArticles = newAndUpdatedArticles.newArticles {
webFeeds.formUnion(Set(newArticles.compactMap { $0.webFeed }))
}
if let updatedArticles = newAndUpdatedArticles.updatedArticles {
webFeeds.formUnion(Set(updatedArticles.compactMap { $0.webFeed }))
}
var shouldSendNotification = false
var userInfo = [String: Any]()
if let newArticles = newAndUpdatedArticles.newArticles, !newArticles.isEmpty {
shouldSendNotification = true
userInfo[UserInfoKey.newArticles] = newArticles
self.updateUnreadCounts(for: webFeeds) {
NotificationCenter.default.post(name: .DownloadArticlesDidUpdateUnreadCounts, object: self, userInfo: nil)
}
}
if let updatedArticles = newAndUpdatedArticles.updatedArticles, !updatedArticles.isEmpty {
shouldSendNotification = true
userInfo[UserInfoKey.updatedArticles] = updatedArticles
}
if shouldSendNotification {
userInfo[UserInfoKey.webFeeds] = webFeeds
NotificationCenter.default.post(name: .AccountDidDownloadArticles, object: self, userInfo: userInfo)
}
}
}
// MARK: - Container Overrides

View File

@ -190,8 +190,9 @@ public final class ArticlesDatabase {
// MARK: - Saving and Updating Articles
/// Update articles and save new ones  for feed-based systems (local and iCloud).
public func update(with feed: ParsedFeed, completion: @escaping UpdateArticlesCompletionBlock) {
public func update(with parsedItems: Set<ParsedItem>, webFeedID: String, completion: @escaping UpdateArticlesCompletionBlock) {
precondition(retentionStyle == .feedBased)
articlesTable.update(parsedItems, webFeedID, completion)
}
/// Update articles and save new ones for sync systems (Feedbin, Feedly, etc.).
@ -268,7 +269,9 @@ public final class ArticlesDatabase {
/// Calls the various clean-up functions.
public func cleanupDatabaseAtStartup(subscribedToWebFeedIDs: Set<String>) {
articlesTable.deleteOldArticles()
if retentionStyle == .syncSystem {
articlesTable.deleteOldArticles()
}
articlesTable.deleteArticlesNotInSubscribedToFeedIDs(subscribedToWebFeedIDs)
}
}

View File

@ -172,7 +172,78 @@ final class ArticlesTable: DatabaseTable {
// MARK: - Updating
func update(_ parsedItems: Set<ParsedItem>, _ webFeedID: String, _ completion: @escaping UpdateArticlesCompletionBlock) {
precondition(retentionStyle == .feedBased)
if parsedItems.isEmpty {
callUpdateArticlesCompletionBlock(nil, nil, completion)
return
}
// 1. Ensure statuses for all the incoming articles.
// 2. Create incoming articles with parsedItems.
// 3. Ignore incoming articles that are userDeleted
// 4. Fetch all articles for the feed.
// 5. Create array of Articles not in database and save them.
// 6. Create array of updated Articles and save whats changed.
// 7. Call back with new and updated Articles.
// 8. Delete Articles in database no longer present in the feed.
// 9. Update search index.
self.queue.runInTransaction { (databaseResult) in
func makeDatabaseCalls(_ database: FMDatabase) {
let articleIDs = parsedItems.articleIDs()
let statusesDictionary = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, false, database) //1
assert(statusesDictionary.count == articleIDs.count)
let allIncomingArticles = Article.articlesWithParsedItems(parsedItems, webFeedID, self.accountID, statusesDictionary) //2
let incomingArticles = Set(allIncomingArticles.filter { !($0.status.userDeleted) }) //3
if incomingArticles.isEmpty {
self.callUpdateArticlesCompletionBlock(nil, nil, completion)
return
}
let fetchedArticles = self.fetchArticlesForFeedID(webFeedID, withLimits: false, database) //4
let fetchedArticlesDictionary = fetchedArticles.dictionary()
let newArticles = self.findAndSaveNewArticles(incomingArticles, fetchedArticlesDictionary, database) //5
let updatedArticles = self.findAndSaveUpdatedArticles(incomingArticles, fetchedArticlesDictionary, database) //6
self.callUpdateArticlesCompletionBlock(newArticles, updatedArticles, completion) //7
self.addArticlesToCache(newArticles)
self.addArticlesToCache(updatedArticles)
// 8. Delete articles no longer in feed.
let articleIDsToDelete = fetchedArticles.articleIDs().filter { !(articleIDs.contains($0)) }
if !articleIDsToDelete.isEmpty {
self.removeArticles(articleIDsToDelete, database)
self.removeArticleIDsFromCache(articleIDsToDelete)
}
// 9. Update search index.
if let newArticles = newArticles {
self.searchTable.indexNewArticles(newArticles, database)
}
if let updatedArticles = updatedArticles {
self.searchTable.indexUpdatedArticles(updatedArticles, database)
}
}
switch databaseResult {
case .success(let database):
makeDatabaseCalls(database)
case .failure(let databaseError):
DispatchQueue.main.async {
completion(.failure(databaseError))
}
}
}
}
func update(_ webFeedIDsAndItems: [String: Set<ParsedItem>], _ read: Bool, _ completion: @escaping UpdateArticlesCompletionBlock) {
precondition(retentionStyle == .syncSystem)
if webFeedIDsAndItems.isEmpty {
callUpdateArticlesCompletionBlock(nil, nil, completion)
return
@ -853,6 +924,12 @@ private extension ArticlesTable {
}
}
func removeArticleIDsFromCache(_ articleIDs: Set<String>) {
for articleID in articleIDs {
articlesCache[articleID] = nil
}
}
func articleIsIgnorable(_ article: Article) -> Bool {
// Ignorable articles: either userDeleted==1 or (not starred and arrival date > 4 months).
if article.status.userDeleted {
@ -866,6 +943,7 @@ private extension ArticlesTable {
func filterIncomingArticles(_ articles: Set<Article>) -> Set<Article> {
// Drop Articles that we can ignore.
precondition(retentionStyle == .syncSystem)
return Set(articles.filter{ !articleIsIgnorable($0) })
}

View File

@ -112,8 +112,12 @@ extension Article {
// return Set(parsedItems.map{ Article(parsedItem: $0, maximumDateAllowed: maximumDateAllowed, accountID: accountID, feedID: feedID, status: statusesDictionary[$0.articleID]!) })
// }
private static func _maximumDateAllowed() -> Date {
return Date().addingTimeInterval(60 * 60 * 24) // Allow dates up to about 24 hours ahead of now
}
static func articlesWithWebFeedIDsAndItems(_ webFeedIDsAndItems: [String: Set<ParsedItem>], _ accountID: String, _ statusesDictionary: [String: ArticleStatus]) -> Set<Article> {
let maximumDateAllowed = Date().addingTimeInterval(60 * 60 * 24) // Allow dates up to about 24 hours ahead of now
let maximumDateAllowed = _maximumDateAllowed()
var feedArticles = Set<Article>()
for (webFeedID, parsedItems) in webFeedIDsAndItems {
for parsedItem in parsedItems {
@ -124,6 +128,11 @@ extension Article {
}
return feedArticles
}
static func articlesWithParsedItems(_ parsedItems: Set<ParsedItem>, _ webFeedID: String, _ accountID: String, _ statusesDictionary: [String: ArticleStatus]) -> Set<Article> {
let maximumDateAllowed = _maximumDateAllowed()
return Set(parsedItems.map{ Article(parsedItem: $0, maximumDateAllowed: maximumDateAllowed, accountID: accountID, webFeedID: webFeedID, status: statusesDictionary[$0.articleID]!) })
}
}
extension Article: DatabaseObject {