@@ -2,13 +2,25 @@ import CoreData
22import WordPressKit
33
44class BloggingPromptsService {
5- private let context : NSManagedObjectContext
5+ private let contextManager : CoreDataStack
66 private let siteID : NSNumber
77 private let remote : BloggingPromptsServiceRemote
88 private let calendar : Calendar = . autoupdatingCurrent
99
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
1224 }
1325
1426 /// Fetches a number of blogging prompts starting from the specified date.
@@ -23,12 +35,18 @@ class BloggingPromptsService {
2335 number: Int = 24 ,
2436 success: @escaping ( [ BloggingPrompt ] ) -> Void ,
2537 failure: @escaping ( Error ? ) -> Void ) {
26- let fromDate = date ?? defaultDate
38+ let fromDate = date ?? defaultStartDate
2739 remote. fetchPrompts ( for: siteID, number: number, fromDate: fromDate) { result in
2840 switch result {
2941 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+ }
3250 case . failure( let error) :
3351 failure ( error)
3452 }
@@ -59,45 +77,114 @@ class BloggingPromptsService {
5977 fetchPrompts ( from: fromDate, number: 11 , success: success, failure: failure)
6078 }
6179
62- required init ? ( context : NSManagedObjectContext = ContextManager . shared. mainContext ,
80+ required init ? ( contextManager : CoreDataStack = ContextManager . shared,
6381 remote: BloggingPromptsServiceRemote ? = nil ,
6482 blog: Blog ? = nil ) {
65- guard let account = AccountService ( managedObjectContext: context ) . defaultWordPressComAccount ( ) ,
83+ guard let account = AccountService ( managedObjectContext: contextManager . mainContext ) . defaultWordPressComAccount ( ) ,
6684 let siteID = blog? . dotComID ?? account. primaryBlogID else {
6785 return nil
6886 }
6987
70- self . context = context
88+ self . contextManager = contextManager
7189 self . siteID = siteID
7290 self . remote = remote ?? . init( wordPressComRestApi: account. wordPressComRestV2Api)
7391 }
7492}
7593
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 ) ]
90128
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+ }
102189 }
103190}
0 commit comments