@@ -2,13 +2,25 @@ import CoreData
2
2
import WordPressKit
3
3
4
4
class BloggingPromptsService {
5
- private let context : NSManagedObjectContext
5
+ private let contextManager : CoreDataStack
6
6
private let siteID : NSNumber
7
7
private let remote : BloggingPromptsServiceRemote
8
8
private let calendar : Calendar = . autoupdatingCurrent
9
9
10
- private var defaultDate : Date {
11
- calendar. date ( byAdding: . day, value: - 10 , to: Date ( ) ) ?? Date ( )
10
+ /// A UTC date formatter that ignores time information.
11
+ private static var dateFormatter : DateFormatter = {
12
+ let formatter = DateFormatter ( )
13
+ formatter. locale = . init( identifier: " en_US_POSIX " )
14
+ formatter. timeZone = . init( secondsFromGMT: 0 )
15
+ formatter. dateFormat = " yyyy-MM-dd "
16
+
17
+ return formatter
18
+ } ( )
19
+
20
+ /// Convenience computed variable that returns today's prompt from local store.
21
+ ///
22
+ var localTodaysPrompt : BloggingPrompt ? {
23
+ loadPrompts ( from: Date ( ) , number: 1 ) . first
12
24
}
13
25
14
26
/// Fetches a number of blogging prompts starting from the specified date.
@@ -23,12 +35,18 @@ class BloggingPromptsService {
23
35
number: Int = 24 ,
24
36
success: @escaping ( [ BloggingPrompt ] ) -> Void ,
25
37
failure: @escaping ( Error ? ) -> Void ) {
26
- let fromDate = date ?? defaultDate
38
+ let fromDate = date ?? defaultStartDate
27
39
remote. fetchPrompts ( for: siteID, number: number, fromDate: fromDate) { result in
28
40
switch result {
29
41
case . success( let remotePrompts) :
30
- // TODO: Upsert into CoreData once the CoreData model is available.
31
- success ( remotePrompts. map { BloggingPrompt ( with: $0) } )
42
+ self . upsert ( with: remotePrompts) { innerResult in
43
+ if case . failure( let error) = innerResult {
44
+ failure ( error)
45
+ return
46
+ }
47
+
48
+ success ( self . loadPrompts ( from: fromDate, number: number) )
49
+ }
32
50
case . failure( let error) :
33
51
failure ( error)
34
52
}
@@ -59,45 +77,114 @@ class BloggingPromptsService {
59
77
fetchPrompts ( from: fromDate, number: 11 , success: success, failure: failure)
60
78
}
61
79
62
- required init ? ( context : NSManagedObjectContext = ContextManager . shared. mainContext ,
80
+ required init ? ( contextManager : CoreDataStack = ContextManager . shared,
63
81
remote: BloggingPromptsServiceRemote ? = nil ,
64
82
blog: Blog ? = nil ) {
65
- guard let account = AccountService ( managedObjectContext: context ) . defaultWordPressComAccount ( ) ,
83
+ guard let account = AccountService ( managedObjectContext: contextManager . mainContext ) . defaultWordPressComAccount ( ) ,
66
84
let siteID = blog? . dotComID ?? account. primaryBlogID else {
67
85
return nil
68
86
}
69
87
70
- self . context = context
88
+ self . contextManager = contextManager
71
89
self . siteID = siteID
72
90
self . remote = remote ?? . init( wordPressComRestApi: account. wordPressComRestV2Api)
73
91
}
74
92
}
75
93
76
- // MARK: - Temporary model object
77
-
78
- /// TODO: This is a temporary model to be replaced with Core Data model once the fields have all been finalized.
79
- struct BloggingPrompt {
80
- let promptID : Int
81
- let text : String
82
- let title : String // for post title
83
- let content : String // for post content
84
- let date : Date
85
- let answered : Bool
86
- let answerCount : Int
87
- let displayAvatarURLs : [ URL ]
88
- let attribution : String
89
- }
94
+ // MARK: - Private Helpers
95
+
96
+ private extension BloggingPromptsService {
97
+
98
+ var defaultStartDate : Date {
99
+ calendar. date ( byAdding: . day, value: - 10 , to: Date ( ) ) ?? Date ( )
100
+ }
101
+
102
+ /// Converts the given date to UTC and ignores the time information.
103
+ /// Example: Given `2022-05-01 03:00:00 UTC-5`, this should return `2022-05-01 00:00:00 UTC`.
104
+ ///
105
+ /// - Parameter date: The date to convert.
106
+ /// - Returns: The UTC date without the time information.
107
+ func utcDateIgnoringTime( from date: Date ) -> Date ? {
108
+ let utcDateString = Self . dateFormatter. string ( from: date)
109
+ return Self . dateFormatter. date ( from: utcDateString)
110
+ }
111
+
112
+ /// Loads local prompts based on the given parameters.
113
+ ///
114
+ /// - Parameters:
115
+ /// - date: When specified, only prompts from the specified date will be returned.
116
+ /// - number: The amount of prompts to return. Defaults to 24 when unspecified.
117
+ /// - Returns: An array of `BloggingPrompt` objects sorted descending by date.
118
+ func loadPrompts( from date: Date , number: Int ) -> [ BloggingPrompt ] {
119
+ guard let utcDate = utcDateIgnoringTime ( from: date) else {
120
+ DDLogError ( " Error converting date to UTC: \( date) " )
121
+ return [ ]
122
+ }
123
+
124
+ let fetchRequest = BloggingPrompt . fetchRequest ( )
125
+ fetchRequest. predicate = . init( format: " \( #keyPath( BloggingPrompt . siteID) ) = %@ AND \( #keyPath( BloggingPrompt . date) ) >= %@ " , siteID, utcDate as NSDate )
126
+ fetchRequest. fetchLimit = number
127
+ fetchRequest. sortDescriptors = [ . init( key: #keyPath( BloggingPrompt . date) , ascending: false ) ]
90
128
91
- extension BloggingPrompt {
92
- init ( with remotePrompt: RemoteBloggingPrompt ) {
93
- promptID = remotePrompt. promptID
94
- text = remotePrompt. text
95
- title = remotePrompt. title
96
- content = remotePrompt. content
97
- date = remotePrompt. date
98
- answered = remotePrompt. answered
99
- answerCount = remotePrompt. answeredUsersCount
100
- displayAvatarURLs = remotePrompt. answeredUserAvatarURLs
101
- attribution = remotePrompt. attribution
129
+ return ( try ? self . contextManager. mainContext. fetch ( fetchRequest) ) ?? [ ]
130
+ }
131
+
132
+ /// Find and update existing prompts, or insert new ones if they don't exist.
133
+ ///
134
+ /// - Parameters:
135
+ /// - remotePrompts: An array containing prompts obtained from remote.
136
+ /// - completion: Closure to be called after the process completes. Returns an array of prompts when successful.
137
+ func upsert( with remotePrompts: [ RemoteBloggingPrompt ] , completion: @escaping ( Result < Void , Error > ) -> Void ) {
138
+ if remotePrompts. isEmpty {
139
+ completion ( . success( ( ) ) )
140
+ return
141
+ }
142
+
143
+ let remoteIDs = Set ( remotePrompts. map { Int32 ( $0. promptID) } )
144
+ let remotePromptsDictionary = remotePrompts. reduce ( into: [ Int32: RemoteBloggingPrompt] ( ) ) { partialResult, remotePrompt in
145
+ partialResult [ Int32 ( remotePrompt. promptID) ] = remotePrompt
146
+ }
147
+
148
+ let predicate = NSPredicate ( format: " \( #keyPath( BloggingPrompt . siteID) ) = %@ AND \( #keyPath( BloggingPrompt . promptID) ) IN %@ " , siteID, remoteIDs)
149
+ let fetchRequest = BloggingPrompt . fetchRequest ( )
150
+ fetchRequest. predicate = predicate
151
+
152
+ let derivedContext = contextManager. newDerivedContext ( )
153
+ derivedContext. perform {
154
+ do {
155
+ // Update existing prompts
156
+ var foundExistingIDs = [ Int32] ( )
157
+ let results = try derivedContext. fetch ( fetchRequest)
158
+ results. forEach { prompt in
159
+ guard let remotePrompt = remotePromptsDictionary [ prompt. promptID] else {
160
+ return
161
+ }
162
+
163
+ foundExistingIDs. append ( prompt. promptID)
164
+ prompt. configure ( with: remotePrompt, for: self . siteID. int32Value)
165
+ }
166
+
167
+ // Insert new prompts
168
+ let newPromptIDs = remoteIDs. subtracting ( foundExistingIDs)
169
+ newPromptIDs. forEach { newPromptID in
170
+ guard let remotePrompt = remotePromptsDictionary [ newPromptID] ,
171
+ let newPrompt = BloggingPrompt . newObject ( in: derivedContext) else {
172
+ return
173
+ }
174
+ newPrompt. configure ( with: remotePrompt, for: self . siteID. int32Value)
175
+ }
176
+
177
+ self . contextManager. save ( derivedContext) {
178
+ DispatchQueue . main. async {
179
+ completion ( . success( ( ) ) )
180
+ }
181
+ }
182
+
183
+ } catch let error {
184
+ DispatchQueue . main. async {
185
+ completion ( . failure( error) )
186
+ }
187
+ }
188
+ }
102
189
}
103
190
}
0 commit comments