forked from zelo72/mastodon-ios
feat: profile persist logic. Add replyTo and replyFrom relationship for Toot
This commit is contained in:
parent
1b654dcabc
commit
807dfd9ea7
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D5029f" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D80" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Application" representedClassName=".Application" syncable="YES">
|
||||
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="name" attributeType="String"/>
|
||||
|
@ -159,6 +159,8 @@
|
|||
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="reblogFrom" inverseEntity="Toot"/>
|
||||
<relationship name="reblogFrom" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="reblog" inverseEntity="Toot"/>
|
||||
<relationship name="rebloggedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="reblogged" inverseEntity="MastodonUser"/>
|
||||
<relationship name="replyFrom" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="replyTo" inverseEntity="Toot"/>
|
||||
<relationship name="replyTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="replyFrom" inverseEntity="Toot"/>
|
||||
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="toot" inverseEntity="Tag"/>
|
||||
</entity>
|
||||
<elements>
|
||||
|
@ -173,6 +175,6 @@
|
|||
<element name="Poll" positionX="72" positionY="162" width="128" height="194"/>
|
||||
<element name="PollOption" positionX="81" positionY="171" width="128" height="134"/>
|
||||
<element name="Tag" positionX="18" positionY="117" width="128" height="119"/>
|
||||
<element name="Toot" positionX="0" positionY="0" width="128" height="539"/>
|
||||
<element name="Toot" positionX="0" positionY="0" width="128" height="14"/>
|
||||
</elements>
|
||||
</model>
|
|
@ -39,6 +39,7 @@ public final class Toot: NSManagedObject {
|
|||
// many-to-one relastionship
|
||||
@NSManaged public private(set) var author: MastodonUser
|
||||
@NSManaged public private(set) var reblog: Toot?
|
||||
@NSManaged public private(set) var replyTo: Toot?
|
||||
|
||||
// many-to-many relastionship
|
||||
@NSManaged public private(set) var favouritedBy: Set<MastodonUser>?
|
||||
|
@ -57,6 +58,7 @@ public final class Toot: NSManagedObject {
|
|||
@NSManaged public private(set) var tags: Set<Tag>?
|
||||
@NSManaged public private(set) var homeTimelineIndexes: Set<HomeTimelineIndex>?
|
||||
@NSManaged public private(set) var mediaAttachments: Set<Attachment>?
|
||||
@NSManaged public private(set) var replyFrom: Set<Toot>?
|
||||
|
||||
@NSManaged public private(set) var updatedAt: Date
|
||||
@NSManaged public private(set) var deletedAt: Date?
|
||||
|
@ -70,6 +72,7 @@ public extension Toot {
|
|||
author: MastodonUser,
|
||||
reblog: Toot?,
|
||||
application: Application?,
|
||||
replyTo: Toot?,
|
||||
poll: Poll?,
|
||||
mentions: [Mention]?,
|
||||
emojis: [Emoji]?,
|
||||
|
|
|
@ -56,7 +56,7 @@
|
|||
2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */; };
|
||||
2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */; };
|
||||
2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */; };
|
||||
2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */; };
|
||||
2D61335825C188A000CAE157 /* APIService+Persist+Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Toot.swift */; };
|
||||
2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; };
|
||||
2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; };
|
||||
2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */; };
|
||||
|
@ -149,6 +149,8 @@
|
|||
DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; };
|
||||
DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; };
|
||||
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; };
|
||||
DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */; };
|
||||
DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */; };
|
||||
DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; };
|
||||
DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; };
|
||||
DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; };
|
||||
|
@ -293,7 +295,7 @@
|
|||
2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||
2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewContainer.swift; sourceTree = "<group>"; };
|
||||
2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+DebugAction.swift"; sourceTree = "<group>"; };
|
||||
2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = "<group>"; };
|
||||
2D61335725C188A000CAE157 /* APIService+Persist+Toot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Toot.swift"; sourceTree = "<group>"; };
|
||||
2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
|
||||
2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error+Detail.swift"; sourceTree = "<group>"; };
|
||||
2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
|
||||
|
@ -395,6 +397,8 @@
|
|||
DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = "<group>"; };
|
||||
DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = "<group>"; };
|
||||
DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = "<group>"; };
|
||||
DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = "<group>"; };
|
||||
DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = "<group>"; };
|
||||
DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = "<group>"; };
|
||||
DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = "<group>"; };
|
||||
DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
|
@ -658,7 +662,9 @@
|
|||
2D61335625C1887F00CAE157 /* Persist */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */,
|
||||
2D61335725C188A000CAE157 /* APIService+Persist+Toot.swift */,
|
||||
DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */,
|
||||
DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */,
|
||||
);
|
||||
path = Persist;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1578,7 +1584,7 @@
|
|||
0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */,
|
||||
DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */,
|
||||
DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */,
|
||||
2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */,
|
||||
2D61335825C188A000CAE157 /* APIService+Persist+Toot.swift in Sources */,
|
||||
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
|
||||
DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */,
|
||||
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */,
|
||||
|
@ -1605,6 +1611,7 @@
|
|||
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */,
|
||||
2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */,
|
||||
2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */,
|
||||
DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */,
|
||||
2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */,
|
||||
0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */,
|
||||
2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */,
|
||||
|
@ -1654,6 +1661,7 @@
|
|||
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */,
|
||||
DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */,
|
||||
DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */,
|
||||
DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */,
|
||||
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */,
|
||||
DB98338725C945ED00AD9700 /* Strings.swift in Sources */,
|
||||
DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */,
|
||||
|
|
|
@ -31,6 +31,7 @@ extension APIService {
|
|||
for: nil,
|
||||
in: domain,
|
||||
entity: account,
|
||||
userCache: nil,
|
||||
networkDate: response.networkDate,
|
||||
log: log)
|
||||
let flag = isCreated ? "+" : "-"
|
||||
|
@ -64,6 +65,7 @@ extension APIService {
|
|||
for: nil,
|
||||
in: domain,
|
||||
entity: account,
|
||||
userCache: nil,
|
||||
networkDate: response.networkDate,
|
||||
log: log)
|
||||
let flag = isCreated ? "+" : "-"
|
||||
|
|
|
@ -136,7 +136,7 @@ extension APIService {
|
|||
.map { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in
|
||||
let log = OSLog.api
|
||||
|
||||
return APIService.Persist.persistTimeline(
|
||||
return APIService.Persist.persistToots(
|
||||
managedObjectContext: self.backgroundManagedObjectContext,
|
||||
domain: mastodonAuthenticationBox.domain,
|
||||
query: query,
|
||||
|
|
|
@ -40,7 +40,7 @@ extension APIService {
|
|||
authorization: authorization
|
||||
)
|
||||
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in
|
||||
return APIService.Persist.persistTimeline(
|
||||
return APIService.Persist.persistToots(
|
||||
managedObjectContext: self.backgroundManagedObjectContext,
|
||||
domain: domain,
|
||||
query: query,
|
||||
|
|
|
@ -39,7 +39,7 @@ extension APIService {
|
|||
query: query
|
||||
)
|
||||
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in
|
||||
return APIService.Persist.persistTimeline(
|
||||
return APIService.Persist.persistToots(
|
||||
managedObjectContext: self.backgroundManagedObjectContext,
|
||||
domain: domain,
|
||||
query: query,
|
||||
|
|
|
@ -18,6 +18,7 @@ extension APIService.CoreData {
|
|||
for requestMastodonUser: MastodonUser?,
|
||||
in domain: String,
|
||||
entity: Mastodon.Entity.Account,
|
||||
userCache: APIService.Persist.PersistCache<MastodonUser>?,
|
||||
networkDate: Date,
|
||||
log: OSLog
|
||||
) -> (user: MastodonUser, isCreated: Bool) {
|
||||
|
@ -29,15 +30,19 @@ extension APIService.CoreData {
|
|||
|
||||
// fetch old mastodon user
|
||||
let oldMastodonUser: MastodonUser? = {
|
||||
let request = MastodonUser.sortedFetchRequest
|
||||
request.predicate = MastodonUser.predicate(domain: domain, id: entity.id)
|
||||
request.fetchLimit = 1
|
||||
request.returnsObjectsAsFaults = false
|
||||
do {
|
||||
return try managedObjectContext.fetch(request).first
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
return nil
|
||||
if let userCache = userCache {
|
||||
return userCache.dictionary[entity.id]
|
||||
} else {
|
||||
let request = MastodonUser.sortedFetchRequest
|
||||
request.predicate = MastodonUser.predicate(domain: domain, id: entity.id)
|
||||
request.fetchLimit = 1
|
||||
request.returnsObjectsAsFaults = false
|
||||
do {
|
||||
return try managedObjectContext.fetch(request).first
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
|
@ -57,7 +62,7 @@ extension APIService.CoreData {
|
|||
into: managedObjectContext,
|
||||
property: mastodonUserProperty
|
||||
)
|
||||
|
||||
userCache?.dictionary[entity.id] = mastodonUser
|
||||
os_signpost(.event, log: log, name: "update database - process entity: createOrMergeMastodonUser", signpostID: processEntityTaskSignpostID, "did insert new mastodon user %{public}s: name %s", mastodonUser.identifier, mastodonUser.username)
|
||||
return (mastodonUser, true)
|
||||
}
|
||||
|
|
|
@ -16,29 +16,49 @@ extension APIService.CoreData {
|
|||
static func createOrMergeToot(
|
||||
into managedObjectContext: NSManagedObjectContext,
|
||||
for requestMastodonUser: MastodonUser?,
|
||||
entity: Mastodon.Entity.Status,
|
||||
domain: String,
|
||||
entity: Mastodon.Entity.Status,
|
||||
tootCache: APIService.Persist.PersistCache<Toot>?,
|
||||
userCache: APIService.Persist.PersistCache<MastodonUser>?,
|
||||
networkDate: Date,
|
||||
log: OSLog
|
||||
) -> (Toot: Toot, isTootCreated: Bool, isMastodonUserCreated: Bool) {
|
||||
|
||||
let processEntityTaskSignpostID = OSSignpostID(log: log)
|
||||
os_signpost(.begin, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id)
|
||||
defer {
|
||||
os_signpost(.end, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id)
|
||||
}
|
||||
|
||||
// build tree
|
||||
let reblog = entity.reblog.flatMap { entity -> Toot in
|
||||
let (toot, _, _) = createOrMergeToot(into: managedObjectContext, for: requestMastodonUser, entity: entity, domain: domain, networkDate: networkDate, log: log)
|
||||
let (toot, _, _) = createOrMergeToot(
|
||||
into: managedObjectContext,
|
||||
for: requestMastodonUser,
|
||||
domain: domain,
|
||||
entity: entity,
|
||||
tootCache: tootCache,
|
||||
userCache: userCache,
|
||||
networkDate: networkDate,
|
||||
log: log
|
||||
)
|
||||
return toot
|
||||
}
|
||||
|
||||
// fetch old Toot
|
||||
let oldToot: Toot? = {
|
||||
let request = Toot.sortedFetchRequest
|
||||
request.predicate = Toot.predicate(domain: domain, id: entity.id)
|
||||
request.fetchLimit = 1
|
||||
request.returnsObjectsAsFaults = false
|
||||
do {
|
||||
return try managedObjectContext.fetch(request).first
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
return nil
|
||||
if let tootCache = tootCache {
|
||||
return tootCache.dictionary[entity.id]
|
||||
} else {
|
||||
let request = Toot.sortedFetchRequest
|
||||
request.predicate = Toot.predicate(domain: domain, id: entity.id)
|
||||
request.fetchLimit = 1
|
||||
request.returnsObjectsAsFaults = false
|
||||
do {
|
||||
return try managedObjectContext.fetch(request).first
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
|
@ -47,10 +67,16 @@ extension APIService.CoreData {
|
|||
APIService.CoreData.merge(toot: oldToot, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate)
|
||||
return (oldToot, false, false)
|
||||
} else {
|
||||
let (mastodonUser, isMastodonUserCreated) = createOrMergeMastodonUser(into: managedObjectContext, for: requestMastodonUser,in: domain, entity: entity.account, networkDate: networkDate, log: log)
|
||||
let (mastodonUser, isMastodonUserCreated) = createOrMergeMastodonUser(into: managedObjectContext, for: requestMastodonUser,in: domain, entity: entity.account, userCache: userCache, networkDate: networkDate, log: log)
|
||||
let application = entity.application.flatMap { app -> Application? in
|
||||
Application.insert(into: managedObjectContext, property: Application.Property(name: app.name, website: app.website, vapidKey: app.vapidKey))
|
||||
}
|
||||
let replyTo: Toot? = {
|
||||
// could be nil if target replyTo toot's persist task in the queue
|
||||
guard let inReplyToID = entity.inReplyToID,
|
||||
let replyTo = tootCache?.dictionary[inReplyToID] else { return nil }
|
||||
return replyTo
|
||||
}()
|
||||
let poll = entity.poll.flatMap { poll -> Poll in
|
||||
let options = poll.options.enumerated().map { i, option -> PollOption in
|
||||
let votedBy: MastodonUser? = (poll.ownVotes ?? []).contains(i) ? requestMastodonUser : nil
|
||||
|
@ -92,6 +118,7 @@ extension APIService.CoreData {
|
|||
author: mastodonUser,
|
||||
reblog: reblog,
|
||||
application: application,
|
||||
replyTo: replyTo,
|
||||
poll: poll,
|
||||
mentions: metions,
|
||||
emojis: emojis,
|
||||
|
@ -103,6 +130,8 @@ extension APIService.CoreData {
|
|||
bookmarkedBy: (entity.bookmarked ?? false) ? requestMastodonUser : nil,
|
||||
pinnedBy: (entity.pinned ?? false) ? requestMastodonUser : nil
|
||||
)
|
||||
tootCache?.dictionary[entity.id] = toot
|
||||
os_signpost(.event, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "did insert new tweet %{public}s: %s", mastodonUser.identifier, entity.id)
|
||||
return (toot, true, isMastodonUserCreated)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
//
|
||||
// APIService+Persist+PersistCache.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-3-10.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
extension APIService.Persist {
|
||||
|
||||
class PersistCache<T> {
|
||||
var dictionary: [String : T] = [:]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension APIService.Persist.PersistCache where T == Toot {
|
||||
|
||||
static func ids(for toots: [Mastodon.Entity.Status]) -> Set<Mastodon.Entity.Status.ID> {
|
||||
var value = Set<String>()
|
||||
for toot in toots {
|
||||
value = value.union(ids(for: toot))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
static func ids(for toot: Mastodon.Entity.Status) -> Set<Mastodon.Entity.Status.ID> {
|
||||
var value = Set<String>()
|
||||
value.insert(toot.id)
|
||||
if let inReplyToID = toot.inReplyToID {
|
||||
value.insert(inReplyToID)
|
||||
}
|
||||
if let reblog = toot.reblog {
|
||||
value = value.union(ids(for: reblog))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension APIService.Persist.PersistCache where T == MastodonUser {
|
||||
|
||||
static func ids(for toots: [Mastodon.Entity.Status]) -> Set<Mastodon.Entity.Account.ID> {
|
||||
var value = Set<String>()
|
||||
for toot in toots {
|
||||
value = value.union(ids(for: toot))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
static func ids(for toot: Mastodon.Entity.Status) -> Set<Mastodon.Entity.Account.ID> {
|
||||
var value = Set<String>()
|
||||
value.insert(toot.account.id)
|
||||
if let inReplyToAccountID = toot.inReplyToAccountID {
|
||||
value.insert(inReplyToAccountID)
|
||||
}
|
||||
if let reblog = toot.reblog {
|
||||
value = value.union(ids(for: reblog))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,226 @@
|
|||
//
|
||||
// APIService+Persist+PersistMemo.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-3-10.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import Foundation
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
extension APIService.Persist {
|
||||
|
||||
class PersistMemo<T, U> {
|
||||
|
||||
let status: T
|
||||
let children: [PersistMemo<T, U>]
|
||||
let memoType: MemoType
|
||||
let statusProcessType: ProcessType
|
||||
let authorProcessType: ProcessType
|
||||
|
||||
enum MemoType {
|
||||
case homeTimeline
|
||||
case mentionTimeline
|
||||
case userTimeline
|
||||
case publicTimeline
|
||||
case likeList
|
||||
case searchList
|
||||
case lookUp
|
||||
|
||||
case reblog
|
||||
|
||||
var flag: String {
|
||||
switch self {
|
||||
case .homeTimeline: return "H"
|
||||
case .mentionTimeline: return "M"
|
||||
case .userTimeline: return "U"
|
||||
case .publicTimeline: return "P"
|
||||
case .likeList: return "L"
|
||||
case .searchList: return "S"
|
||||
case .lookUp: return "LU"
|
||||
case .reblog: return "R"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ProcessType {
|
||||
case create
|
||||
case merge
|
||||
|
||||
var flag: String {
|
||||
switch self {
|
||||
case .create: return "+"
|
||||
case .merge: return "~"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
status: T,
|
||||
children: [PersistMemo<T, U>],
|
||||
memoType: MemoType,
|
||||
statusProcessType: ProcessType,
|
||||
authorProcessType: ProcessType
|
||||
) {
|
||||
self.status = status
|
||||
self.children = children
|
||||
self.memoType = memoType
|
||||
self.statusProcessType = statusProcessType
|
||||
self.authorProcessType = authorProcessType
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension APIService.Persist.PersistMemo {
|
||||
|
||||
struct Counting {
|
||||
var status = Counter()
|
||||
var user = Counter()
|
||||
|
||||
static func + (left: Counting, right: Counting) -> Counting {
|
||||
return Counting(
|
||||
status: left.status + right.status,
|
||||
user: left.user + right.user
|
||||
)
|
||||
}
|
||||
|
||||
struct Counter {
|
||||
var create = 0
|
||||
var merge = 0
|
||||
|
||||
static func + (left: Counter, right: Counter) -> Counter {
|
||||
return Counter(
|
||||
create: left.create + right.create,
|
||||
merge: left.merge + right.merge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func count() -> Counting {
|
||||
var counting = Counting()
|
||||
|
||||
switch statusProcessType {
|
||||
case .create: counting.status.create += 1
|
||||
case .merge: counting.status.merge += 1
|
||||
}
|
||||
|
||||
switch authorProcessType {
|
||||
case .create: counting.user.create += 1
|
||||
case .merge: counting.user.merge += 1
|
||||
}
|
||||
|
||||
for child in children {
|
||||
let childCounting = child.count()
|
||||
counting = counting + childCounting
|
||||
}
|
||||
|
||||
return counting
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension APIService.Persist.PersistMemo where T == Toot, U == MastodonUser {
|
||||
|
||||
static func createOrMergeToot(
|
||||
into managedObjectContext: NSManagedObjectContext,
|
||||
for requestMastodonUser: MastodonUser?,
|
||||
requestMastodonUserID: MastodonUser.ID?,
|
||||
domain: String,
|
||||
entity: Mastodon.Entity.Status,
|
||||
memoType: MemoType,
|
||||
tootCache: APIService.Persist.PersistCache<T>?,
|
||||
userCache: APIService.Persist.PersistCache<U>?,
|
||||
networkDate: Date,
|
||||
log: OSLog
|
||||
) -> APIService.Persist.PersistMemo<T, U> {
|
||||
let processEntityTaskSignpostID = OSSignpostID(log: log)
|
||||
os_signpost(.begin, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id)
|
||||
defer {
|
||||
os_signpost(.end, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "finish process toot %{public}s", entity.id)
|
||||
}
|
||||
|
||||
// build tree
|
||||
let reblogMemo = entity.reblog.flatMap { entity -> APIService.Persist.PersistMemo<T, U> in
|
||||
createOrMergeToot(
|
||||
into: managedObjectContext,
|
||||
for: requestMastodonUser,
|
||||
requestMastodonUserID: requestMastodonUserID,
|
||||
domain: domain,
|
||||
entity: entity,
|
||||
memoType: .reblog,
|
||||
tootCache: tootCache,
|
||||
userCache: userCache,
|
||||
networkDate: networkDate,
|
||||
log: log
|
||||
)
|
||||
}
|
||||
let children = [reblogMemo].compactMap { $0 }
|
||||
|
||||
|
||||
let (toot, isTootCreated, isMastodonUserCreated) = APIService.CoreData.createOrMergeToot(
|
||||
into: managedObjectContext,
|
||||
for: requestMastodonUser,
|
||||
domain: domain,
|
||||
entity: entity,
|
||||
tootCache: tootCache,
|
||||
userCache: userCache,
|
||||
networkDate: networkDate,
|
||||
log: log
|
||||
)
|
||||
let memo = APIService.Persist.PersistMemo<T, U>(
|
||||
status: toot,
|
||||
children: children,
|
||||
memoType: memoType,
|
||||
statusProcessType: isTootCreated ? .create : .merge,
|
||||
authorProcessType: isMastodonUserCreated ? .create : .merge
|
||||
)
|
||||
|
||||
switch (memo.statusProcessType, memoType) {
|
||||
case (.create, .homeTimeline), (.merge, .homeTimeline):
|
||||
let timelineIndex = toot.homeTimelineIndexes?
|
||||
.first { $0.userID == requestMastodonUserID }
|
||||
guard let requestMastodonUserID = requestMastodonUserID else {
|
||||
assertionFailure()
|
||||
break
|
||||
}
|
||||
if timelineIndex == nil {
|
||||
// make it indexed
|
||||
let timelineIndexProperty = HomeTimelineIndex.Property(domain: domain, userID: requestMastodonUserID)
|
||||
let _ = HomeTimelineIndex.insert(into: managedObjectContext, property: timelineIndexProperty, toot: toot)
|
||||
} else {
|
||||
// enity already in home timeline
|
||||
}
|
||||
case (.create, .mentionTimeline), (.merge, .mentionTimeline):
|
||||
break
|
||||
// TODO:
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return memo
|
||||
}
|
||||
|
||||
func log(indentLevel: Int = 0) -> String {
|
||||
let indent = Array(repeating: " ", count: indentLevel).joined()
|
||||
let preview = status.content.prefix(32).replacingOccurrences(of: "\n", with: " ")
|
||||
let message = "\(indent)[\(statusProcessType.flag)\(memoType.flag)](\(status.id)) [\(authorProcessType.flag)](\(status.author.id))@\(status.author.username) ~> \(preview)"
|
||||
|
||||
var childrenMessages: [String] = []
|
||||
for child in children {
|
||||
childrenMessages.append(child.log(indentLevel: indentLevel + 1))
|
||||
}
|
||||
let result = [[message] + childrenMessages]
|
||||
.flatMap { $0 }
|
||||
.joined(separator: "\n")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,446 +0,0 @@
|
|||
//
|
||||
// APIService+Persist+Timeline.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/1/27.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import func QuartzCore.CACurrentMediaTime
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
extension APIService.Persist {
|
||||
|
||||
enum PersistTimelineType {
|
||||
case `public`
|
||||
case home
|
||||
case likeList
|
||||
}
|
||||
|
||||
static func persistTimeline(
|
||||
managedObjectContext: NSManagedObjectContext,
|
||||
domain: String,
|
||||
query: Mastodon.API.Timeline.TimelineQuery,
|
||||
response: Mastodon.Response.Content<[Mastodon.Entity.Status]>,
|
||||
persistType: PersistTimelineType,
|
||||
requestMastodonUserID: MastodonUser.ID?, // could be nil when response from public endpoint
|
||||
log: OSLog
|
||||
) -> AnyPublisher<Result<Void, Error>, Never> {
|
||||
let toots = response.value
|
||||
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: persist %{public}ld toots…", ((#file as NSString).lastPathComponent), #line, #function, toots.count)
|
||||
|
||||
return managedObjectContext.performChanges {
|
||||
let contextTaskSignpostID = OSSignpostID(log: log)
|
||||
let start = CACurrentMediaTime()
|
||||
os_signpost(.begin, log: log, name: #function, signpostID: contextTaskSignpostID)
|
||||
defer {
|
||||
os_signpost(.end, log: .api, name: #function, signpostID: contextTaskSignpostID)
|
||||
let end = CACurrentMediaTime()
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: persist cost %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start)
|
||||
}
|
||||
|
||||
// load request mastodon user
|
||||
let requestMastodonUser: MastodonUser? = {
|
||||
guard let requestMastodonUserID = requestMastodonUserID else { return nil }
|
||||
let request = MastodonUser.sortedFetchRequest
|
||||
request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID)
|
||||
request.fetchLimit = 1
|
||||
request.returnsObjectsAsFaults = false
|
||||
do {
|
||||
return try managedObjectContext.fetch(request).first
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
|
||||
// load working set into context to avoid cache miss
|
||||
let cacheTaskSignpostID = OSSignpostID(log: log)
|
||||
os_signpost(.begin, log: log, name: "load toots into cache", signpostID: cacheTaskSignpostID)
|
||||
let workingIDRecord = APIService.Persist.WorkingIDRecord.workingID(entities: toots)
|
||||
|
||||
// contains toots and reblogs
|
||||
let _tootCache: [Toot] = {
|
||||
let request = Toot.sortedFetchRequest
|
||||
let idSet = workingIDRecord.statusIDSet
|
||||
.union(workingIDRecord.reblogIDSet)
|
||||
let ids = Array(idSet)
|
||||
request.predicate = Toot.predicate(domain: domain, ids: ids)
|
||||
request.returnsObjectsAsFaults = false
|
||||
request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)]
|
||||
do {
|
||||
return try managedObjectContext.fetch(request)
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
return []
|
||||
}
|
||||
}()
|
||||
os_signpost(.event, log: log, name: "load toots into cache", signpostID: cacheTaskSignpostID, "cached %{public}ld toots", _tootCache.count)
|
||||
os_signpost(.end, log: log, name: "load toots into cache", signpostID: cacheTaskSignpostID)
|
||||
|
||||
// remote timeline merge local timeline record set
|
||||
// declare it before do working
|
||||
let mergedOldTootsInTimeline = _tootCache.filter {
|
||||
return $0.homeTimelineIndexes?.contains(where: { $0.userID == requestMastodonUserID }) ?? false
|
||||
}
|
||||
|
||||
let updateDatabaseTaskSignpostID = OSSignpostID(log: log)
|
||||
let recordType: WorkingRecord.RecordType = {
|
||||
switch persistType {
|
||||
case .public: return .publicTimeline
|
||||
case .home: return .homeTimeline
|
||||
case .likeList: return .favoriteTimeline
|
||||
}
|
||||
}()
|
||||
|
||||
var workingRecords: [WorkingRecord] = []
|
||||
os_signpost(.begin, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID)
|
||||
for entity in toots {
|
||||
let processEntityTaskSignpostID = OSSignpostID(log: log)
|
||||
os_signpost(.begin, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id)
|
||||
defer {
|
||||
os_signpost(.end, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id)
|
||||
}
|
||||
let record = WorkingRecord.createOrMergeToot(
|
||||
into: managedObjectContext,
|
||||
for: requestMastodonUser,
|
||||
domain: domain,
|
||||
entity: entity,
|
||||
recordType: recordType,
|
||||
networkDate: response.networkDate,
|
||||
log: log
|
||||
)
|
||||
workingRecords.append(record)
|
||||
} // end for…
|
||||
os_signpost(.end, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID)
|
||||
|
||||
// home & mention timeline tasks
|
||||
switch persistType {
|
||||
case .home:
|
||||
// Task 1: update anchor hasMore
|
||||
// update maxID anchor hasMore attribute when fetching on timeline
|
||||
// do not use working records due to anchor toot is removable on the remote
|
||||
var anchorToot: Toot?
|
||||
if let maxID = query.maxID {
|
||||
do {
|
||||
// load anchor toot from database
|
||||
let request = Toot.sortedFetchRequest
|
||||
request.predicate = Toot.predicate(domain: domain, id: maxID)
|
||||
request.returnsObjectsAsFaults = false
|
||||
request.fetchLimit = 1
|
||||
anchorToot = try managedObjectContext.fetch(request).first
|
||||
if persistType == .home {
|
||||
let timelineIndex = anchorToot.flatMap { toot in
|
||||
toot.homeTimelineIndexes?.first(where: { $0.userID == requestMastodonUserID })
|
||||
}
|
||||
timelineIndex?.update(hasMore: false)
|
||||
} else {
|
||||
assertionFailure()
|
||||
}
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// Task 2: set last toot hasMore when fetched toots not overlap with the timeline in the local database
|
||||
let _oldestRecord = workingRecords
|
||||
.sorted(by: { $0.status.createdAt < $1.status.createdAt })
|
||||
.first
|
||||
if let oldestRecord = _oldestRecord {
|
||||
if let anchorToot = anchorToot {
|
||||
// using anchor. set hasMore when (overlap itself OR no overlap) AND oldest record NOT anchor
|
||||
let isNoOverlap = mergedOldTootsInTimeline.isEmpty
|
||||
let isOnlyOverlapItself = mergedOldTootsInTimeline.count == 1 && mergedOldTootsInTimeline.first?.id == anchorToot.id
|
||||
let isAnchorEqualOldestRecord = oldestRecord.status.id == anchorToot.id
|
||||
if (isNoOverlap || isOnlyOverlapItself) && !isAnchorEqualOldestRecord {
|
||||
if persistType == .home {
|
||||
let timelineIndex = oldestRecord.status.homeTimelineIndexes?
|
||||
.first(where: { $0.userID == requestMastodonUserID })
|
||||
timelineIndex?.update(hasMore: true)
|
||||
} else {
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
|
||||
} else if mergedOldTootsInTimeline.isEmpty {
|
||||
// no anchor. set hasMore when no overlap
|
||||
if persistType == .home {
|
||||
let timelineIndex = oldestRecord.status.homeTimelineIndexes?
|
||||
.first(where: { $0.userID == requestMastodonUserID })
|
||||
timelineIndex?.update(hasMore: true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// empty working record. mark anchor hasMore in the task 1
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// print working record tree map
|
||||
#if DEBUG
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let logs = workingRecords
|
||||
.map { record in record.log() }
|
||||
.joined(separator: "\n")
|
||||
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: working status: \n%s", ((#file as NSString).lastPathComponent), #line, #function, logs)
|
||||
let counting = workingRecords
|
||||
.map { record in record.count() }
|
||||
.reduce(into: WorkingRecord.Counting(), { result, next in result = result + next })
|
||||
let newTootsInTimeLineCount = workingRecords.reduce(0, { result, next in
|
||||
return next.statusProcessType == .create ? result + 1 : result
|
||||
})
|
||||
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: toot: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTootsInTimeLineCount, counting.status.create, mergedOldTootsInTimeline.count, counting.status.merge)
|
||||
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user: insert %{public}ld, merge %{public}ld", ((#file as NSString).lastPathComponent), #line, #function, counting.user.create, counting.user.merge)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
.handleEvents(receiveOutput: { result in
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
#if DEBUG
|
||||
debugPrint(error)
|
||||
#endif
|
||||
assertionFailure(error.localizedDescription)
|
||||
}
|
||||
})
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
extension APIService.Persist {
|
||||
|
||||
struct WorkingIDRecord {
|
||||
var statusIDSet: Set<String>
|
||||
var reblogIDSet: Set<String>
|
||||
var userIDSet: Set<String>
|
||||
|
||||
enum RecordType {
|
||||
case timeline
|
||||
case reblog
|
||||
}
|
||||
|
||||
init(statusIDSet: Set<String> = Set(), reblogIDSet: Set<String> = Set(), userIDSet: Set<String> = Set()) {
|
||||
self.statusIDSet = statusIDSet
|
||||
self.reblogIDSet = reblogIDSet
|
||||
self.userIDSet = userIDSet
|
||||
}
|
||||
|
||||
mutating func union(record: WorkingIDRecord) {
|
||||
statusIDSet = statusIDSet.union(record.statusIDSet)
|
||||
reblogIDSet = reblogIDSet.union(record.reblogIDSet)
|
||||
userIDSet = userIDSet.union(record.userIDSet)
|
||||
}
|
||||
|
||||
static func workingID(entities: [Mastodon.Entity.Status]) -> WorkingIDRecord {
|
||||
var value = WorkingIDRecord()
|
||||
for entity in entities {
|
||||
let child = workingID(entity: entity, recordType: .timeline)
|
||||
value.union(record: child)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private static func workingID(entity: Mastodon.Entity.Status, recordType: RecordType) -> WorkingIDRecord {
|
||||
var value = WorkingIDRecord()
|
||||
switch recordType {
|
||||
case .timeline: value.statusIDSet = Set([entity.id])
|
||||
case .reblog: value.reblogIDSet = Set([entity.id])
|
||||
}
|
||||
value.userIDSet = Set([entity.account.id])
|
||||
|
||||
if let reblog = entity.reblog {
|
||||
let child = workingID(entity: reblog, recordType: .reblog)
|
||||
value.union(record: child)
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
class WorkingRecord {
|
||||
|
||||
let status: Toot
|
||||
let children: [WorkingRecord]
|
||||
let recordType: RecordType
|
||||
let statusProcessType: ProcessType
|
||||
let userProcessType: ProcessType
|
||||
|
||||
init(
|
||||
status: Toot,
|
||||
children: [APIService.Persist.WorkingRecord],
|
||||
recordType: APIService.Persist.WorkingRecord.RecordType,
|
||||
tootProcessType: ProcessType,
|
||||
userProcessType: ProcessType
|
||||
) {
|
||||
self.status = status
|
||||
self.children = children
|
||||
self.recordType = recordType
|
||||
self.statusProcessType = tootProcessType
|
||||
self.userProcessType = userProcessType
|
||||
}
|
||||
|
||||
enum RecordType {
|
||||
case publicTimeline
|
||||
case homeTimeline
|
||||
case mentionTimeline
|
||||
case userTimeline
|
||||
case favoriteTimeline
|
||||
case searchTimeline
|
||||
|
||||
case reblog
|
||||
|
||||
var flag: String {
|
||||
switch self {
|
||||
case .publicTimeline: return "P"
|
||||
case .homeTimeline: return "H"
|
||||
case .mentionTimeline: return "M"
|
||||
case .userTimeline: return "U"
|
||||
case .favoriteTimeline: return "F"
|
||||
case .searchTimeline: return "S"
|
||||
case .reblog: return "R"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ProcessType {
|
||||
case create
|
||||
case merge
|
||||
|
||||
var flag: String {
|
||||
switch self {
|
||||
case .create: return "+"
|
||||
case .merge: return "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func log(indentLevel: Int = 0) -> String {
|
||||
let indent = Array(repeating: " ", count: indentLevel).joined()
|
||||
let tootPreview = status.content.prefix(32).replacingOccurrences(of: "\n", with: " ")
|
||||
let message = "\(indent)[\(statusProcessType.flag)\(recordType.flag)](\(status.id)) [\(userProcessType.flag)](\(status.author.id))@\(status.author.username) ~> \(tootPreview)"
|
||||
|
||||
var childrenMessages: [String] = []
|
||||
for child in children {
|
||||
childrenMessages.append(child.log(indentLevel: indentLevel + 1))
|
||||
}
|
||||
let result = [[message] + childrenMessages]
|
||||
.flatMap { $0 }
|
||||
.joined(separator: "\n")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
struct Counting {
|
||||
var status = Counter()
|
||||
var user = Counter()
|
||||
|
||||
static func + (left: Counting, right: Counting) -> Counting {
|
||||
return Counting(
|
||||
status: left.status + right.status,
|
||||
user: left.user + right.user
|
||||
)
|
||||
}
|
||||
|
||||
struct Counter {
|
||||
var create = 0
|
||||
var merge = 0
|
||||
|
||||
static func + (left: Counter, right: Counter) -> Counter {
|
||||
return Counter(
|
||||
create: left.create + right.create,
|
||||
merge: left.merge + right.merge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func count() -> Counting {
|
||||
var counting = Counting()
|
||||
|
||||
switch statusProcessType {
|
||||
case .create: counting.status.create += 1
|
||||
case .merge: counting.status.merge += 1
|
||||
}
|
||||
|
||||
switch userProcessType {
|
||||
case .create: counting.user.create += 1
|
||||
case .merge: counting.user.merge += 1
|
||||
}
|
||||
|
||||
for child in children {
|
||||
let childCounting = child.count()
|
||||
counting = counting + childCounting
|
||||
}
|
||||
|
||||
return counting
|
||||
}
|
||||
|
||||
// handle timelineIndex insert with APIService.Persist.createOrMergeToot
|
||||
static func createOrMergeToot(
|
||||
into managedObjectContext: NSManagedObjectContext,
|
||||
for requestMastodonUser: MastodonUser?,
|
||||
domain: String,
|
||||
entity: Mastodon.Entity.Status,
|
||||
recordType: RecordType,
|
||||
networkDate: Date,
|
||||
log: OSLog
|
||||
) -> WorkingRecord {
|
||||
let processEntityTaskSignpostID = OSSignpostID(log: log)
|
||||
os_signpost(.begin, log: log, name: "update database - process entity: createorMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id)
|
||||
defer {
|
||||
os_signpost(.end, log: log, name: "update database - process entity: createorMergeToot", signpostID: processEntityTaskSignpostID, "finish process toot %{public}s", entity.id)
|
||||
}
|
||||
|
||||
// build tree
|
||||
let reblogRecord: WorkingRecord? = entity.reblog.flatMap { entity -> WorkingRecord in
|
||||
createOrMergeToot(into: managedObjectContext, for: requestMastodonUser, domain: domain, entity: entity, recordType: .reblog, networkDate: networkDate, log: log)
|
||||
}
|
||||
let children = [reblogRecord].compactMap { $0 }
|
||||
|
||||
let (status, isTootCreated, isTootUserCreated) = APIService.CoreData.createOrMergeToot(into: managedObjectContext, for: requestMastodonUser, entity: entity, domain: domain, networkDate: networkDate, log: log)
|
||||
|
||||
let result = WorkingRecord(
|
||||
status: status,
|
||||
children: children,
|
||||
recordType: recordType,
|
||||
tootProcessType: isTootCreated ? .create : .merge,
|
||||
userProcessType: isTootUserCreated ? .create : .merge
|
||||
)
|
||||
|
||||
switch (result.statusProcessType, recordType) {
|
||||
case (.create, .homeTimeline), (.merge, .homeTimeline):
|
||||
guard let requestMastodonUserID = requestMastodonUser?.id else {
|
||||
assertionFailure("Request user is required for home timeline")
|
||||
break
|
||||
}
|
||||
let timelineIndex = status.homeTimelineIndexes?
|
||||
.first { $0.userID == requestMastodonUserID }
|
||||
if timelineIndex == nil {
|
||||
let timelineIndexProperty = HomeTimelineIndex.Property(domain: domain, userID: requestMastodonUserID)
|
||||
|
||||
let _ = HomeTimelineIndex.insert(
|
||||
into: managedObjectContext,
|
||||
property: timelineIndexProperty,
|
||||
toot: status
|
||||
)
|
||||
} else {
|
||||
// enity already in home timeline
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
//
|
||||
// APIService+Persist+Toot.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/1/27.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import func QuartzCore.CACurrentMediaTime
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
extension APIService.Persist {
|
||||
|
||||
enum PersistTimelineType {
|
||||
case `public`
|
||||
case home
|
||||
case likeList
|
||||
}
|
||||
|
||||
static func persistToots(
|
||||
managedObjectContext: NSManagedObjectContext,
|
||||
domain: String,
|
||||
query: Mastodon.API.Timeline.TimelineQuery,
|
||||
response: Mastodon.Response.Content<[Mastodon.Entity.Status]>,
|
||||
persistType: PersistTimelineType,
|
||||
requestMastodonUserID: MastodonUser.ID?, // could be nil when response from public endpoint
|
||||
log: OSLog
|
||||
) -> AnyPublisher<Result<Void, Error>, Never> {
|
||||
return managedObjectContext.performChanges {
|
||||
let toots = response.value
|
||||
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: persist %{public}ld toots…", ((#file as NSString).lastPathComponent), #line, #function, toots.count)
|
||||
|
||||
let contextTaskSignpostID = OSSignpostID(log: log)
|
||||
let start = CACurrentMediaTime()
|
||||
os_signpost(.begin, log: log, name: #function, signpostID: contextTaskSignpostID)
|
||||
defer {
|
||||
os_signpost(.end, log: .api, name: #function, signpostID: contextTaskSignpostID)
|
||||
let end = CACurrentMediaTime()
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: persist cost %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start)
|
||||
}
|
||||
|
||||
// load request mastodon user
|
||||
let requestMastodonUser: MastodonUser? = {
|
||||
guard let requestMastodonUserID = requestMastodonUserID else { return nil }
|
||||
let request = MastodonUser.sortedFetchRequest
|
||||
request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID)
|
||||
request.fetchLimit = 1
|
||||
request.returnsObjectsAsFaults = false
|
||||
do {
|
||||
return try managedObjectContext.fetch(request).first
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
|
||||
// load working set into context to avoid cache miss
|
||||
let cacheTaskSignpostID = OSSignpostID(log: log)
|
||||
os_signpost(.begin, log: log, name: "load toots & users into cache", signpostID: cacheTaskSignpostID)
|
||||
|
||||
// contains reblog
|
||||
let tootCache: PersistCache<Toot> = {
|
||||
let cache = PersistCache<Toot>()
|
||||
let cacheIDs = PersistCache<Toot>.ids(for: toots)
|
||||
let cachedToots: [Toot] = {
|
||||
let request = Toot.sortedFetchRequest
|
||||
let ids = Array(cacheIDs)
|
||||
request.predicate = Toot.predicate(domain: domain, ids: ids)
|
||||
request.returnsObjectsAsFaults = false
|
||||
request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)]
|
||||
do {
|
||||
return try managedObjectContext.fetch(request)
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
return []
|
||||
}
|
||||
}()
|
||||
for toot in cachedToots {
|
||||
cache.dictionary[toot.id] = toot
|
||||
}
|
||||
os_signpost(.event, log: log, name: "load toot into cache", signpostID: cacheTaskSignpostID, "cached %{public}ld toots", cachedToots.count)
|
||||
return cache
|
||||
}()
|
||||
|
||||
let userCache: PersistCache<MastodonUser> = {
|
||||
let cache = PersistCache<MastodonUser>()
|
||||
let cacheIDs = PersistCache<MastodonUser>.ids(for: toots)
|
||||
let cachedMastodonUsers: [MastodonUser] = {
|
||||
let request = MastodonUser.sortedFetchRequest
|
||||
let ids = Array(cacheIDs)
|
||||
request.predicate = MastodonUser.predicate(domain: domain, ids: ids)
|
||||
//request.returnsObjectsAsFaults = false
|
||||
do {
|
||||
return try managedObjectContext.fetch(request)
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
return []
|
||||
}
|
||||
}()
|
||||
for mastodonuser in cachedMastodonUsers {
|
||||
cache.dictionary[mastodonuser.id] = mastodonuser
|
||||
}
|
||||
os_signpost(.event, log: log, name: "load user into cache", signpostID: cacheTaskSignpostID, "cached %{public}ld users", cachedMastodonUsers.count)
|
||||
return cache
|
||||
}()
|
||||
|
||||
os_signpost(.end, log: log, name: "load toots & users into cache", signpostID: cacheTaskSignpostID)
|
||||
|
||||
// remote timeline merge local timeline record set
|
||||
// declare it before persist
|
||||
let mergedOldTootsInTimeline = tootCache.dictionary.values.filter {
|
||||
return $0.homeTimelineIndexes?.contains(where: { $0.userID == requestMastodonUserID }) ?? false
|
||||
}
|
||||
|
||||
let updateDatabaseTaskSignpostID = OSSignpostID(log: log)
|
||||
let memoType: PersistMemo<Toot, MastodonUser>.MemoType = {
|
||||
switch persistType {
|
||||
case .home: return .homeTimeline
|
||||
case .public: return .publicTimeline
|
||||
case .likeList: return .likeList
|
||||
}
|
||||
}()
|
||||
|
||||
var persistMemos: [PersistMemo<Toot, MastodonUser>] = []
|
||||
os_signpost(.begin, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID)
|
||||
for entity in toots {
|
||||
let processEntityTaskSignpostID = OSSignpostID(log: log)
|
||||
os_signpost(.begin, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id)
|
||||
defer {
|
||||
os_signpost(.end, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id)
|
||||
}
|
||||
let memo = PersistMemo.createOrMergeToot(
|
||||
into: managedObjectContext,
|
||||
for: requestMastodonUser,
|
||||
requestMastodonUserID: requestMastodonUserID,
|
||||
domain: domain,
|
||||
entity: entity,
|
||||
memoType: memoType,
|
||||
tootCache: tootCache,
|
||||
userCache: userCache,
|
||||
networkDate: response.networkDate,
|
||||
log: log
|
||||
)
|
||||
persistMemos.append(memo)
|
||||
} // end for…
|
||||
os_signpost(.end, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID)
|
||||
|
||||
// home timeline tasks
|
||||
switch persistType {
|
||||
case .home:
|
||||
guard let requestMastodonUserID = requestMastodonUserID else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
// Task 1: update anchor hasMore
|
||||
// update maxID anchor hasMore attribute when fetching on home timeline
|
||||
// do not use working records due to anchor toot is removable on the remote
|
||||
var anchorToot: Toot?
|
||||
if let maxID = query.maxID {
|
||||
do {
|
||||
// load anchor toot from database
|
||||
let request = Toot.sortedFetchRequest
|
||||
request.predicate = Toot.predicate(domain: domain, id: maxID)
|
||||
request.returnsObjectsAsFaults = false
|
||||
request.fetchLimit = 1
|
||||
anchorToot = try managedObjectContext.fetch(request).first
|
||||
if persistType == .home {
|
||||
let timelineIndex = anchorToot.flatMap { toot in
|
||||
toot.homeTimelineIndexes?.first(where: { $0.userID == requestMastodonUserID })
|
||||
}
|
||||
timelineIndex?.update(hasMore: false)
|
||||
} else {
|
||||
assertionFailure()
|
||||
}
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// Task 2: set last toot hasMore when fetched toots not overlap with the timeline in the local database
|
||||
let _oldestMemo = persistMemos
|
||||
.sorted(by: { $0.status.createdAt < $1.status.createdAt })
|
||||
.first
|
||||
if let oldestMemo = _oldestMemo {
|
||||
if let anchorToot = anchorToot {
|
||||
// using anchor. set hasMore when (overlap itself OR no overlap) AND oldest record NOT anchor
|
||||
let isNoOverlap = mergedOldTootsInTimeline.isEmpty
|
||||
let isOnlyOverlapItself = mergedOldTootsInTimeline.count == 1 && mergedOldTootsInTimeline.first?.id == anchorToot.id
|
||||
let isAnchorEqualOldestRecord = oldestMemo.status.id == anchorToot.id
|
||||
if (isNoOverlap || isOnlyOverlapItself) && !isAnchorEqualOldestRecord {
|
||||
if persistType == .home {
|
||||
let timelineIndex = oldestMemo.status.homeTimelineIndexes?
|
||||
.first(where: { $0.userID == requestMastodonUserID })
|
||||
timelineIndex?.update(hasMore: true)
|
||||
} else {
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
|
||||
} else if mergedOldTootsInTimeline.isEmpty {
|
||||
// no anchor. set hasMore when no overlap
|
||||
if persistType == .home {
|
||||
let timelineIndex = oldestMemo.status.homeTimelineIndexes?
|
||||
.first(where: { $0.userID == requestMastodonUserID })
|
||||
timelineIndex?.update(hasMore: true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// empty working record. mark anchor hasMore in the task 1
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// print working record tree map
|
||||
#if DEBUG
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let logs = persistMemos
|
||||
.map { record in record.log() }
|
||||
.joined(separator: "\n")
|
||||
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: working status: \n%s", ((#file as NSString).lastPathComponent), #line, #function, logs)
|
||||
let counting = persistMemos
|
||||
.map { record in record.count() }
|
||||
.reduce(into: PersistMemo.Counting(), { result, next in result = result + next })
|
||||
let newTweetsInTimeLineCount = persistMemos.reduce(0, { result, next in
|
||||
return next.statusProcessType == .create ? result + 1 : result
|
||||
})
|
||||
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: tweet: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTweetsInTimeLineCount, counting.status.create, mergedOldTootsInTimeline.count, counting.status.merge)
|
||||
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: twitter user: insert %{public}ld, merge %{public}ld", ((#file as NSString).lastPathComponent), #line, #function, counting.user.create, counting.user.merge)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
.handleEvents(receiveOutput: { result in
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
#if DEBUG
|
||||
debugPrint(error)
|
||||
#endif
|
||||
assertionFailure(error.localizedDescription)
|
||||
}
|
||||
})
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue