diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 24a3955e7..f635a3db0 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -149,6 +149,7 @@ + @@ -194,24 +195,25 @@ + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - + \ No newline at end of file diff --git a/CoreDataStack/Entity/History.swift b/CoreDataStack/Entity/History.swift index 114879298..6fe703e84 100644 --- a/CoreDataStack/Entity/History.swift +++ b/CoreDataStack/Entity/History.swift @@ -40,6 +40,26 @@ public extension History { } } +public extension History { + func update(day: Date) { + if self.day != day { + self.day = day + } + } + + func update(uses: String) { + if self.uses != uses { + self.uses = uses + } + } + + func update(accounts: String) { + if self.accounts != accounts { + self.accounts = accounts + } + } +} + public extension History { struct Property { public let day: Date diff --git a/CoreDataStack/Entity/SearchHistory.swift b/CoreDataStack/Entity/SearchHistory.swift index 33b8a6010..d924917ee 100644 --- a/CoreDataStack/Entity/SearchHistory.swift +++ b/CoreDataStack/Entity/SearchHistory.swift @@ -12,6 +12,7 @@ public final class SearchHistory: NSManagedObject { public typealias ID = UUID @NSManaged public private(set) var identifier: ID @NSManaged public private(set) var createAt: Date + @NSManaged public private(set) var updatedAt: Date @NSManaged public private(set) var account: MastodonUser? @NSManaged public private(set) var hashtag: Tag? @@ -22,6 +23,13 @@ extension SearchHistory { public override func awakeFromInsert() { super.awakeFromInsert() setPrimitiveValue(UUID(), forKey: #keyPath(SearchHistory.identifier)) + setPrimitiveValue(Date(), forKey: #keyPath(SearchHistory.createAt)) + setPrimitiveValue(Date(), forKey: #keyPath(SearchHistory.updatedAt)) + } + + public override func willSave() { + super.willSave() + setPrimitiveValue(Date(), forKey: #keyPath(SearchHistory.updatedAt)) } @discardableResult @@ -31,7 +39,6 @@ extension SearchHistory { ) -> SearchHistory { let searchHistory: SearchHistory = context.insertObject() searchHistory.account = account - searchHistory.createAt = Date() return searchHistory } @@ -42,13 +49,18 @@ extension SearchHistory { ) -> SearchHistory { let searchHistory: SearchHistory = context.insertObject() searchHistory.hashtag = hashtag - searchHistory.createAt = Date() return searchHistory } } +public extension SearchHistory { + func update(updatedAt: Date) { + setValue(updatedAt, forKey: #keyPath(SearchHistory.updatedAt)) + } +} + extension SearchHistory: Managed { public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \SearchHistory.createAt, ascending: false)] + return [NSSortDescriptor(keyPath: \SearchHistory.updatedAt, ascending: false)] } } diff --git a/CoreDataStack/Entity/Tag.swift b/CoreDataStack/Entity/Tag.swift index 2f1914c4a..3044cacc0 100644 --- a/CoreDataStack/Entity/Tag.swift +++ b/CoreDataStack/Entity/Tag.swift @@ -12,25 +12,33 @@ public final class Tag: NSManagedObject { public typealias ID = UUID @NSManaged public private(set) var identifier: ID @NSManaged public private(set) var createAt: Date - + @NSManaged public private(set) var updatedAt: Date + @NSManaged public private(set) var name: String @NSManaged public private(set) var url: String - + // many-to-many relationship @NSManaged public private(set) var statuses: Set? - + // one-to-many relationship @NSManaged public private(set) var histories: Set? } -extension Tag { - public override func awakeFromInsert() { +public extension Tag { + override func awakeFromInsert() { super.awakeFromInsert() setPrimitiveValue(UUID(), forKey: #keyPath(Tag.identifier)) + setPrimitiveValue(Date(), forKey: #keyPath(Tag.createAt)) + setPrimitiveValue(Date(), forKey: #keyPath(Tag.updatedAt)) } - + + override func willSave() { + super.willSave() + setPrimitiveValue(Date(), forKey: #keyPath(Tag.updatedAt)) + } + @discardableResult - public static func insert( + static func insert( into context: NSManagedObjectContext, property: Property ) -> Tag { @@ -44,8 +52,8 @@ extension Tag { } } -extension Tag { - public struct Property { +public extension Tag { + struct Property { public let name: String public let url: String public let histories: [History]? @@ -58,8 +66,36 @@ extension Tag { } } -extension Tag: Managed { - public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \Tag.createAt, ascending: false)] +public extension Tag { + func updateHistory(index: Int, day: Date, uses: String, account: String) { + guard let histories = self.histories?.sorted(by: { + $0.createAt.compare($1.createAt) == .orderedAscending + }) else { return } + let history = histories[index] + history.update(day: day) + history.update(uses: uses) + history.update(accounts: account) + } + + func appendHistory(history: History) { + self.mutableSetValue(forKeyPath: #keyPath(Tag.histories)).add(history) + } + + func update(url: String) { + if self.url != url { + self.url = url + } + } +} + +extension Tag: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + [NSSortDescriptor(keyPath: \Tag.createAt, ascending: false)] + } +} + +public extension Tag { + static func predicate(name: String) -> NSPredicate { + NSPredicate(format: "%K == %@", #keyPath(Tag.name), name) } } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 97b65eeb1..d002ccd1a 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -30,7 +30,6 @@ 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D33725E6401400AAD544 /* PickServerCell.swift */; }; 18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; }; 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; }; - 2D0B7A0B261D5A5600B44727 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0B7A0A261D5A5600B44727 /* Array.swift */; }; 2D0B7A1D261D839600B44727 /* SearchHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0B7A1C261D839600B44727 /* SearchHistory.swift */; }; 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* StatusView.swift */; }; 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; }; @@ -92,6 +91,7 @@ 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76319E25C1521200929FB9 /* StatusSection.swift */; }; 2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */; }; 2D7631B325C159F700929FB9 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631B225C159F700929FB9 /* Item.swift */; }; + 2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */; }; 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */; }; 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */; }; 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */; }; @@ -401,7 +401,6 @@ 0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerSearchCell.swift; sourceTree = ""; }; 0FB3D33725E6401400AAD544 /* PickServerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCell.swift; sourceTree = ""; }; 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = ""; }; - 2D0B7A0A261D5A5600B44727 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; 2D0B7A1C261D839600B44727 /* SearchHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistory.swift; sourceTree = ""; }; 2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = ""; }; 2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; @@ -460,6 +459,7 @@ 2D76319E25C1521200929FB9 /* StatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusSection.swift; sourceTree = ""; }; 2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewCell.swift; sourceTree = ""; }; 2D7631B225C159F700929FB9 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = ""; }; + 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Tag.swift"; sourceTree = ""; }; 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewControllerAppearance.swift; sourceTree = ""; }; 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModelNavigationDelegateShim.swift; sourceTree = ""; }; 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarTitleViewModel.swift; sourceTree = ""; }; @@ -1321,6 +1321,7 @@ 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Status.swift */, DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */, DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */, + 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */, ); path = CoreData; sourceTree = ""; @@ -1550,7 +1551,6 @@ 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */, DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */, DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, - 2D0B7A0A261D5A5600B44727 /* Array.swift */, DB68A06225E905E000CFDF14 /* UIApplication.swift */, DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */, 2D42FF8E25C8228A004A627A /* UIButton.swift */, @@ -2207,6 +2207,7 @@ 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */, DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, + 2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */, @@ -2284,7 +2285,6 @@ 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, - 2D0B7A0B261D5A5600B44727 /* Array.swift in Sources */, DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, diff --git a/Mastodon/Extension/Array.swift b/Mastodon/Extension/Array.swift deleted file mode 100644 index 91c2e3d66..000000000 --- a/Mastodon/Extension/Array.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// Array.swift -// Mastodon -// -// Created by sxiaojian on 2021/4/7. -// - -import Foundation - -public extension Array where Element: Equatable { - - func removeDuplicate() -> Array { - return self.enumerated().filter { (index,value) -> Bool in - return self.firstIndex(of: value) == index - }.map { (_, value) in - value - } - } -} - diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift index 08090f804..f7ff5f33e 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift @@ -82,7 +82,8 @@ extension SearchRecommendTagsCollectionViewCell { peopleLabel.text = "" return } - let recentHistory = historys[0...2] + + let recentHistory = historys.prefix(2) let peopleAreTalking = recentHistory.compactMap({ Int($0.accounts) }).reduce(0, +) let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) peopleLabel.text = string diff --git a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift index 7088f1360..c76ab202c 100644 --- a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift @@ -89,7 +89,8 @@ extension SearchViewModel.LoadOldestState { var newAccounts = [Mastodon.Entity.Account]() newAccounts.append(contentsOf: oldSearchResult.accounts) newAccounts.append(contentsOf: result.value.accounts) - viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: newAccounts.removeDuplicate(), statuses: oldSearchResult.statuses, hashtags: oldSearchResult.hashtags) + newAccounts.removeDuplicates() + viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: newAccounts, statuses: oldSearchResult.statuses, hashtags: oldSearchResult.hashtags) stateMachine.enter(Idle.self) } case Mastodon.API.Search.SearchType.hashtags: @@ -99,7 +100,8 @@ extension SearchViewModel.LoadOldestState { var newTags = [Mastodon.Entity.Tag]() newTags.append(contentsOf: oldSearchResult.hashtags) newTags.append(contentsOf: result.value.hashtags) - viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: oldSearchResult.accounts, statuses: oldSearchResult.statuses, hashtags: newTags.removeDuplicate()) + newTags.removeDuplicates() + viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: oldSearchResult.accounts, statuses: oldSearchResult.statuses, hashtags: newTags) stateMachine.enter(Idle.self) } default: diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 06b654b3d..18954665c 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -234,6 +234,7 @@ final class SearchViewModel: NSObject { } func saveItemToCoreData(item: SearchResultItem) { + let searchHistories = self.fetchSearchHistory() _ = context.managedObjectContext.performChanges { [weak self] in guard let self = self else { return } switch item { @@ -255,15 +256,55 @@ final class SearchViewModel: NSObject { } }() let (mastodonUser, _) = APIService.CoreData.createOrMergeMastodonUser(into: self.context.managedObjectContext, for: requestMastodonUser, in: activeMastodonAuthenticationBox.domain, entity: account, userCache: nil, networkDate: Date(), log: OSLog.api) - SearchHistory.insert(into: self.context.managedObjectContext, account: mastodonUser) + if let searchHistories = searchHistories { + let history = searchHistories.first { history -> Bool in + guard let account = history.account else { return false } + return account.objectID == mastodonUser.objectID + } + if let history = history { + history.update(updatedAt: Date()) + } else { + SearchHistory.insert(into: self.context.managedObjectContext, account: mastodonUser) + } + } else { + SearchHistory.insert(into: self.context.managedObjectContext, account: mastodonUser) + } case .hashtag(let tag): - let histories = tag.history?[0 ... 2].compactMap { history -> History in - History.insert(into: self.context.managedObjectContext, property: History.Property(day: history.day, uses: history.uses, accounts: history.accounts)) + let (tagInCoreData,_) = APIService.CoreData.createOrMergeTag(into: self.context.managedObjectContext, entity: tag) + if let searchHistories = searchHistories { + let history = searchHistories.first { history -> Bool in + guard let hashtag = history.hashtag else { return false } + return hashtag.objectID == tagInCoreData.objectID + } + if let history = history { + history.update(updatedAt: Date()) + } else { + SearchHistory.insert(into: self.context.managedObjectContext, hashtag: tagInCoreData) + } + } else { + SearchHistory.insert(into: self.context.managedObjectContext, hashtag: tagInCoreData) + } + case .accountObjectID(let accountObjectID): + if let searchHistories = searchHistories { + let history = searchHistories.first { history -> Bool in + guard let account = history.account else { return false } + return account.objectID == accountObjectID + } + if let history = history { + history.update(updatedAt: Date()) + } + } + case .hashtagObjectID(let hashtagObjectID): + if let searchHistories = searchHistories { + let history = searchHistories.first { history -> Bool in + guard let hashtag = history.hashtag else { return false } + return hashtag.objectID == hashtagObjectID + } + if let history = history { + history.update(updatedAt: Date()) + } } - let tagInCoreData = Tag.insert(into: self.context.managedObjectContext, property: Tag.Property(name: tag.name, url: tag.url, histories: histories)) - SearchHistory.insert(into: self.context.managedObjectContext, hashtag: tagInCoreData) - default: break } diff --git a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift index aab8a5706..9fe0f1336 100644 --- a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift +++ b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift @@ -96,7 +96,7 @@ extension SearchingTableViewCell { _subTitleLabel.text = "" return } - let recentHistory = historys[0 ... 2] + let recentHistory = historys.prefix(2) let peopleAreTalking = recentHistory.compactMap { Int($0.accounts) }.reduce(0, +) let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) _subTitleLabel.text = string @@ -112,7 +112,7 @@ extension SearchingTableViewCell { _subTitleLabel.text = "" return } - let recentHistory = historys[0 ... 2] + let recentHistory = historys.prefix(2) let peopleAreTalking = recentHistory.compactMap { Int($0.accounts) }.reduce(0, +) let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) _subTitleLabel.text = string diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift new file mode 100644 index 000000000..3f931ddea --- /dev/null +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift @@ -0,0 +1,66 @@ +// +// APIService+CoreData+Tag.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/8. +// + +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK + +extension APIService.CoreData { + static func createOrMergeTag( + into managedObjectContext: NSManagedObjectContext, + entity: Mastodon.Entity.Tag + ) -> (Tag: Tag, isCreated: Bool) { + // fetch old mastodon user + let oldTag: Tag? = { + let request = Tag.sortedFetchRequest + request.predicate = Tag.predicate(name: entity.name) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + if let oldTag = oldTag { + APIService.CoreData.merge(tag: oldTag, entity: entity, into: managedObjectContext) + return (oldTag, false) + } else { + let histories = entity.history?.prefix(2).compactMap { history -> History in + History.insert(into: managedObjectContext, property: History.Property(day: history.day, uses: history.uses, accounts: history.accounts)) + } + let tagInCoreData = Tag.insert(into: managedObjectContext, property: Tag.Property(name: entity.name, url: entity.url, histories: histories)) + return (tagInCoreData, true) + } + } + + static func merge(tag:Tag,entity:Mastodon.Entity.Tag,into managedObjectContext: NSManagedObjectContext) { + tag.update(url: tag.url) + guard let tagHistories = tag.histories else { return } + guard let entityHistories = entity.history?.prefix(2) else { return } + let entityHistoriesCount = entityHistories.count + if entityHistoriesCount == 0 { + return + } + for n in 0..