Continue work on tags. Build broken.

This commit is contained in:
Brent Simmons 2017-07-10 20:54:00 -07:00
parent d02013cb3a
commit c79580b87c
8 changed files with 285 additions and 18 deletions

View File

@ -26,7 +26,7 @@ public final class Article: Hashable {
public var datePublished: Date?
public var dateModified: Date?
public var authors: [Author]?
public var tags: [String]?
public var tags: Set<String>?
public var attachments: [Attachment]?
public var accountInfo: [String: Any]? //If account needs to store more data
@ -39,7 +39,7 @@ public final class Article: Hashable {
}
}
init(account: Account, feedID: String, uniqueID: String, title: String?, contentHTML: String?, contentText: String?, url: String?, externalURL: String?, summary: String?, imageURL: String?, bannerImageURL: String?, datePublished: Date?, dateModified: Date?, authors: [Author]?, tags: [String]?, attachments: [Attachment]?, accountInfo: AccountInfo?) {
init(account: Account, feedID: String, uniqueID: String, title: String?, contentHTML: String?, contentText: String?, url: String?, externalURL: String?, summary: String?, imageURL: String?, bannerImageURL: String?, datePublished: Date?, dateModified: Date?, authors: [Author]?, tags: Set<String>?, attachments: [Attachment]?, accountInfo: AccountInfo?) {
self.account = account
self.feedID = feedID

View File

@ -84,8 +84,6 @@
844BEE961F0AB4F8004AB7CD /* DisplayNameProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DisplayNameProvider.swift; sourceTree = "<group>"; };
844BEE971F0AB4F8004AB7CD /* UnreadCountProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnreadCountProvider.swift; sourceTree = "<group>"; };
844BEE9C1F0AB512004AB7CD /* RSCore.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSCore.xcodeproj; path = ../RSCore/RSCore.xcodeproj; sourceTree = "<group>"; };
84BB4B7C1F1177AD00858766 /* ArticleCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleCache.swift; sourceTree = "<group>"; };
84BB4B801F1178E400858766 /* StatusesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusesManager.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -120,7 +118,6 @@
844BEE841F0AB4DB004AB7CD /* ArticleStatus.swift */,
844BEE861F0AB4E3004AB7CD /* BatchUpdates.swift */,
844BEE881F0AB4E7004AB7CD /* Notifications.swift */,
84BB4B7B1F1177AD00858766 /* Database */,
844BEE8A1F0AB4EF004AB7CD /* OPML */,
844BEE931F0AB4F8004AB7CD /* Protocols */,
844BEE761F0AB444004AB7CD /* Info.plist */,
@ -188,15 +185,6 @@
name = Frameworks;
sourceTree = "<group>";
};
84BB4B7B1F1177AD00858766 /* Database */ = {
isa = PBXGroup;
children = (
84BB4B7C1F1177AD00858766 /* ArticleCache.swift */,
84BB4B801F1178E400858766 /* StatusesManager.swift */,
);
path = Database;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXHeadersBuildPhase section */

View File

@ -12,6 +12,7 @@ public struct DatabaseTableName {
static let articles = "articles"
static let statuses = "statuses"
static let tags = "tags"
}
public struct DatabaseKey {
@ -47,5 +48,8 @@ public struct DatabaseKey {
static let mimeType = "mimeType"
static let sizeInBytes = "sizeInBytes"
static let durationInSeconds = "durationInSeconds"
// Tag
static let tagName = "tagName"
}

View File

@ -1,5 +1,12 @@
CREATE TABLE if not EXISTS articles (articleID TEXT NOT NULL PRIMARY KEY, feedID TEXT NOT NULL, uniqueID TEXT, title TEXT, contentHTML TEXT, contentText TEXT, url TEXT, externalURL TEXT, summary TEXT, imageURL TEXT, bannerImageURL TEXT, datePublished DATE, dateModified DATE, authors BLOB, tags BLOB, attachments BLOB, accountInfo BLOB);
CREATE TABLE if not EXISTS articles (articleID TEXT NOT NULL PRIMARY KEY, feedID TEXT NOT NULL, uniqueID TEXT, title TEXT, contentHTML TEXT, contentText TEXT, url TEXT, externalURL TEXT, summary TEXT, imageURL TEXT, bannerImageURL TEXT, datePublished DATE, dateModified DATE, accountInfo BLOB);
CREATE TABLE if not EXISTS statuses (articleID TEXT NOT NULL PRIMARY KEY, read BOOL NOT NULL DEFAULT 0, starred BOOL NOT NULL DEFAULT 0, userDeleted BOOL NOT NULL DEFAULT 0, dateArrived DATE NOT NULL DEFAULT 0, accountInfo BLOB);
CREATE TABLE if not EXISTS authors (authorID TEXT NOT NULL PRIMARY KEY, name TEXT, url TEXT, avatarURL TEXT, emailAddress TEXT);
CREATE TABLE if not EXISTS authorLookup (authorID TEXT NOT NULL, articleID TEXT NOT NULL, PRIMARY KEY(authorID, articleID));
CREATE TABLE if not EXISTS tags(tagName TEXT NOT NULL, articleID TEXT NOT NULL, PRIMARY KEY(tagName, articleID));
CREATE TABLE if not EXISTS attachments(articleID TEXT NOT NULL, url TEXT NOT NULL, mimeType TEXT, title TEXT, sizeInBytes INTEGER, durationInSeconds INTEGER, PRIMARY KEY(articleID, url));
CREATE INDEX if not EXISTS feedIndex on articles (feedID);

View File

@ -17,6 +17,8 @@
8455807C1F0C0DBD003CCFA1 /* Attachment+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8455807B1F0C0DBD003CCFA1 /* Attachment+Database.swift */; };
846146271F0ABC7B00870CB3 /* RSParser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 846146241F0ABC7400870CB3 /* RSParser.framework */; };
84BB4BA21F119C5400858766 /* RSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84BB4B981F119C4900858766 /* RSCore.framework */; };
84BB4BA41F119D4A00858766 /* Author+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BB4BA31F119D4A00858766 /* Author+Database.swift */; };
84BB4BA91F11A32800858766 /* TagsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BB4BA81F11A32800858766 /* TagsManager.swift */; };
84E156EA1F0AB80500F8CC05 /* Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E156E91F0AB80500F8CC05 /* Database.swift */; };
84E156EC1F0AB80E00F8CC05 /* ArticlesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E156EB1F0AB80E00F8CC05 /* ArticlesManager.swift */; };
84E156EE1F0AB81400F8CC05 /* StatusesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E156ED1F0AB81400F8CC05 /* StatusesManager.swift */; };
@ -118,6 +120,8 @@
8455807B1F0C0DBD003CCFA1 /* Attachment+Database.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "Attachment+Database.swift"; path = "Extensions/Attachment+Database.swift"; sourceTree = "<group>"; };
8461461E1F0ABC7300870CB3 /* RSParser.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSParser.xcodeproj; path = ../RSParser/RSParser.xcodeproj; sourceTree = "<group>"; };
84BB4B8F1F119C4900858766 /* RSCore.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSCore.xcodeproj; path = ../RSCore/RSCore.xcodeproj; sourceTree = "<group>"; };
84BB4BA31F119D4A00858766 /* Author+Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "Author+Database.swift"; path = "Extensions/Author+Database.swift"; sourceTree = "<group>"; };
84BB4BA81F11A32800858766 /* TagsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsManager.swift; sourceTree = "<group>"; };
84E156E81F0AB75600F8CC05 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
84E156E91F0AB80500F8CC05 /* Database.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Database.swift; sourceTree = "<group>"; };
84E156EB1F0AB80E00F8CC05 /* ArticlesManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArticlesManager.swift; sourceTree = "<group>"; };
@ -157,6 +161,7 @@
845580661F0AEBCD003CCFA1 /* Constants.swift */,
84E156EB1F0AB80E00F8CC05 /* ArticlesManager.swift */,
84E156ED1F0AB81400F8CC05 /* StatusesManager.swift */,
84BB4BA81F11A32800858766 /* TagsManager.swift */,
8461462A1F0AC44100870CB3 /* Extensions */,
84E156EF1F0AB81F00F8CC05 /* CreateStatements.sql */,
84E156E81F0AB75600F8CC05 /* Info.plist */,
@ -200,6 +205,7 @@
845580751F0AF670003CCFA1 /* Article+Database.swift */,
845580791F0AF67D003CCFA1 /* ArticleStatus+Database.swift */,
8455807B1F0C0DBD003CCFA1 /* Attachment+Database.swift */,
84BB4BA31F119D4A00858766 /* Author+Database.swift */,
845580711F0AEE49003CCFA1 /* PropertyListTransformer.swift */,
);
name = Extensions;
@ -227,10 +233,10 @@
84E156FB1F0AB83A00F8CC05 /* Frameworks */ = {
isa = PBXGroup;
children = (
84E156F11F0AB83600F8CC05 /* Data.xcodeproj */,
84BB4B8F1F119C4900858766 /* RSCore.xcodeproj */,
8461461E1F0ABC7300870CB3 /* RSParser.xcodeproj */,
84E157001F0AB89B00F8CC05 /* RSDatabase.xcodeproj */,
84E156F11F0AB83600F8CC05 /* Data.xcodeproj */,
);
name = Frameworks;
sourceTree = "<group>";
@ -458,6 +464,8 @@
845580761F0AF670003CCFA1 /* Article+Database.swift in Sources */,
845580721F0AEE49003CCFA1 /* PropertyListTransformer.swift in Sources */,
8455807A1F0AF67D003CCFA1 /* ArticleStatus+Database.swift in Sources */,
84BB4BA41F119D4A00858766 /* Author+Database.swift in Sources */,
84BB4BA91F11A32800858766 /* TagsManager.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -0,0 +1,39 @@
//
// Author+Database.swift
// Database
//
// Created by Brent Simmons on 7/8/17.
// Copyright © 2017 Ranchero Software. All rights reserved.
//
import Foundation
extension Author {
private static let
convenience init?(databaseDictionary d: [String: Any]) {
guard let url = d[DatabaseKey.url] as? String else {
return nil
}
let mimeType = d[DatabaseKey.mimeType] as? String
let title = d[DatabaseKey.title] as? String
let durationInSeconds = d[DatabaseKey.durationInSeconds] as? Int
self.init(url: url, mimeType: mimeType, title: title, durationInSeconds: durationInSeconds)
self.init(name: name, url: url, avatarURL: avatarURL, emailAddress: emailAddress)
}
class func attachments(with plist: [Any]) -> [Attachment]? {
return plist.flatMap{ (oneDictionary) -> Attachment? in
if let d = oneDictionary as? [String: Any] {
return Attachment(databaseDictionary: d)
}
return nil
}
}
}

View File

@ -44,6 +44,17 @@ struct PropertyListTransformer {
return Attachment.attachments(with: plist)
}
static func authorsWithRow(_ row: FMResultSet) -> [Author]? {
guard let d = row.data(forColumn: DatabaseKey.authors) else {
return nil
}
guard let plist = propertyList(withData: d) as? [Any] else {
return nil
}
return Author.authors(with: plist)
}
static func propertyListWithRow(_ row: FMResultSet, column: String) -> Any? {
guard let rawData = row.data(forColumn: column) else {

View File

@ -0,0 +1,210 @@
//
// TagsManager.swift
// Database
//
// Created by Brent Simmons on 7/8/17.
// Copyright © 2017 Ranchero Software. All rights reserved.
//
import Foundation
import RSDatabase
// Tags  and the non-existence of tags are cached, once fetched, for the lifetime of the run.
// This uses some extra memory but cuts way down on the amount of database time spent
// maintaining the tags table.
final class TagsManager {
private var articleIDCache = [String: <String>]() // articleID: tag
private var articleIDsWithNoTags = Set<String>()
private let queue: RSDatabaseQueue
init(queue: RSDatabaseQueue) {
self.queue = queue
}
func saveTagsForArticles(_ articles: Set<Article>) {
var articlesToSaveTags = Set<Article>()
var articlesToRemoveTags = Set<Article>()
articles.forEach { (oneArticle) in
if articleTagsMatchCache(oneArticle) {
return
}
if let tags = oneArticle.tags {
articlesToSaveTags.insert(oneArticle)
}
else {
articlesToRemoveTags.insert(oneArticle)
}
}
if !articlesToSaveTags.isEmpty {
updateTagsForArticles(articlesToSaveTags)
}
if !articlesToRemoveTags.isEmpty {
removeArticleFromTags(articlesToRemoveTags)
}
}
}
typealias TagNameSet = Set<String>
private extension TagsManager {
func cacheTagsForArticle(_ article: Article, tags: TagNameSet) {
articleIDsWithNoTags.remove(article.articleID)
articleIDCache[article.articleID] = tags
}
func cachedTagsForArticleID(_ articleID: String) -> TagNameSet? {
return articleIDsCache[articleID]
}
func articleTagsMatchCache(_ article: Article) -> Bool {
if let tags = article.tags {
return tags == articleIDCache[article.articleID]
}
return articleIDIsKnowToHaveNoTags(article.articleID)
}
func articleIDIsKnownToHaveNoTags(_ articleID: String) -> Bool {
return articleIDsWithNoTags.contains(articleID)
}
func removeTagsFromCacheForArticleID(_ articleID: String) {
articleIDsCache[oneArticleID] = nil
articleIDsWithNoTags.insert(oneArticleID)
}
func removeArticleFromTags(_ articles: Set<Article>) {
var articleIDsToRemove = [String]()
articles.forEach { (oneArticle) in
let oneArticleID = oneArticle.articleID
if articleIDIsKnownToHaveNoTags(oneArticle) {
return
}
articleIDsToRemove += oneArticleID
removeTagsFromCacheForArticleID(oneArticleID)
}
if !articleIDsToRemove.isEmpty {
queue.update { (database) in
database.rs_deleteRowsWhereKey(DatabaseKey.articleID, inValues: articleIDsToRemove, tableName: DatabaseTableName.tags)
}
}
}
typealias TagsTable = [String: TagNameSet] // [articleID: Set<tagName>]
func updateTagsForArticles(_ articles: Set<Article>) {
var tagsForArticleIDs = TagsTable()
articles.forEach { (oneArticle)
if let tags = oneArticle.tags {
cacheTagsForArticle(oneArticle, tags)
tagsForArticleIDs[oneArticle.articleID] = oneArticle.tags
}
else {
assertionFailure("article must have tags")
}
}
if tagsForArticleIDs.isEmpty { // Shouldnt be empty
return
}
let articleIDs = tagsForArticleIDs.keys
queue.update { (database) in
let existingTags = self.fetchTagsForArticleIDs(articleIDs, database: database)
self.syncIncomingAndExistingTags(incomingTags: tagsForArticleIDs, existingTags: existingTags, database: database)
}
}
func syncIncomingAndExistingTags(incomingTags: TagsTable, existingTags: TagsTable, database: database) {
for (oneArticleID, oneTagNames) in incomingTags {
if let existingTagNames = existingTags[oneArticleID] {
syncIncomingAndExistingTagsForArticleID(oneArticleID, incomingTagNames: oneTagNames, existingTagNames: existingTagNames, database: database)
}
else {
saveIncomingTagsForArticleID(oneArticleID, tagNames: oneTagNames, database: database)
}
}
}
func saveIncomingTagsForArticleID(_ articleID: String, tagNames: TagNameSet, database: FMDatabase) {
// No existing tags in database. Simple save.
for oneTagName in tagNames {
let oneDictionary = [DatabaseTableName.articleID: articleID, DatabaseTableName.tagName: oneTagName]
database.rs_insertRow(with: oneDictionary, insertType: .OrIgnore, tableName: DatabaseTableName.tags)
}
}
func syncingIncomingAndExistingTagsForArticleID(_ articleID: String, incomingTagNames: TagNameSet, existingTagNames: TagNameSet, database: FMDatabase) {
if incomingTagNames == existingTagNames {
return
}
var tagsToRemove = TagNameSet()
for oneExistingTagName in existingTagNames {
if !incomingTagNames.contains(oneExistingTagName) {
tagsToRemove.insert(oneExistingTagName)
}
}
var tagsToAdd = TagNameSet()
for oneIncomingTagName in incomingTagNames {
if !existingTagNames.contains(oneIncomingTagName) {
tagsToAdd.insert(oneIncomingTagName)
}
}
if !tagsToRemove.isEmpty {
let placeholders = NSString.rs_SQLValueListWithPlaceholders
let sql = "delete from \(DatabaseTableName.tags) where \(DatabaseKey.articleID) = ? and \(DatabaseKey.tagName) in "
database.executeUpdate(sql, withArgumentsIn: [articleID, ])
}
}
func fetchTagsForArticleIDs(_ articleIDs: Set<String>, database: FMDatabase) -> TagsTable] {
var tagSpecifiers = TagsTable()
guard let rs = database.rs_selectRowsWhereKey(DatabaseKey.articleID, inValues: Array(articleIDs), tableName: DatabaseTableName.tags) else {
return tagSpecifiers
}
while rs.next() {
guard let oneTagName = rs.string(forColumn: DatabaseKey.tagName), oneArticleID = rs.string(forColumn: DatabaseKey.articleID) else {
continue
}
if tagSpecifiers[oneArticleID] == nil {
tagSpecifiers[oneArticleID] = Set([oneTagName])
}
else {
tagSpecifiers[oneArticleID]!.insert(oneTagName)
}
}
return tagSpecifiers
}
}