@@ -2,9 +2,11 @@ import CoreData
22import WordPressKit
33
44class BloggingPromptsService {
5- private let contextManager : CoreDataStackSwift
65 let siteID : NSNumber
7- private let remote : BloggingPromptsServiceRemote
6+
7+ private let contextManager : CoreDataStackSwift
8+ private let remote : BloggingPromptsServiceRemote // TODO: Remove once the settings logic is ported.
9+ private let api : WordPressComRestApi
810 private let calendar : Calendar = . autoupdatingCurrent
911 private let maxListPrompts = 11
1012
@@ -27,6 +29,14 @@ class BloggingPromptsService {
2729 return formatter
2830 } ( )
2931
32+ /// A JSON decoder that can parse date strings that matches `JSONDecoder.DateDecodingStrategy.DateFormat` into `Date`.
33+ private static var jsonDecoder : JSONDecoder = {
34+ let decoder = JSONDecoder ( )
35+ decoder. dateDecodingStrategy = JSONDecoder . DateDecodingStrategy. supportMultipleDateFormats
36+
37+ return decoder
38+ } ( )
39+
3040 /// Convenience computed variable that returns today's prompt from local store.
3141 ///
3242 var localTodaysPrompt : BloggingPrompt ? {
@@ -48,7 +58,8 @@ class BloggingPromptsService {
4858 success: ( ( [ BloggingPrompt ] ) -> Void ) ? = nil ,
4959 failure: ( ( Error ? ) -> Void ) ? = nil ) {
5060 let fromDate = startDate ?? defaultStartDate
51- remote. fetchPrompts ( for: siteID, number: number, fromDate: fromDate) { result in
61+
62+ fetchRemotePrompts ( number: number, fromDate: fromDate, ignoresYear: true ) { result in
5263 switch result {
5364 case . success( let remotePrompts) :
5465 self . upsert ( with: remotePrompts) { innerResult in
@@ -181,11 +192,14 @@ class BloggingPromptsService {
181192 /// Otherwise, a remote service with the default account's credentials will be used.
182193 /// - blog: When supplied, the service will perform blogging prompts requests for this specified blog.
183194 /// Otherwise, this falls back to the default account's primary blog.
195+ /// - api: When supplied, the WordPressComRestApi instance to use to fetch the prompts.
196+ /// Otherwise, an default or anonymous instance will be computed based on whether there is an account available.
184197 required init ? ( contextManager: CoreDataStackSwift = ContextManager . shared,
198+ api: WordPressComRestApi ? = nil ,
185199 remote: BloggingPromptsServiceRemote ? = nil ,
186200 blog: Blog ? = nil ) {
187201 let blogObjectID = blog? . objectID
188- let ( siteID, remoteInstance) = contextManager. performQuery { mainContext in
202+ let ( siteID, remoteInstance, api ) = contextManager. performQuery { mainContext in
189203 // if a blog exists, then try to use the blog's ID.
190204 var blogInContext : Blog ? = nil
191205 if let blogObjectID {
@@ -194,12 +208,18 @@ class BloggingPromptsService {
194208
195209 // fetch the default account and fall back to default values as needed.
196210 guard let account = try ? WPAccount . lookupDefaultWordPressComAccount ( in: mainContext) else {
197- return ( blogInContext? . dotComID, remote)
211+ return (
212+ blogInContext? . dotComID,
213+ remote,
214+ api ?? WordPressComRestApi . anonymousApi ( userAgent: WPUserAgent . wordPress ( ) ,
215+ localeKey: WordPressComRestApi . LocaleKeyV2)
216+ )
198217 }
199218
200219 return (
201220 blogInContext? . dotComID ?? account. primaryBlogID,
202- remote ?? . init( wordPressComRestApi: account. wordPressComRestV2Api)
221+ remote ?? . init( wordPressComRestApi: api ?? account. wordPressComRestV2Api) ,
222+ api ?? account. wordPressComRestV2Api
203223 )
204224 }
205225
@@ -211,6 +231,7 @@ class BloggingPromptsService {
211231 self . contextManager = contextManager
212232 self . siteID = siteID
213233 self . remote = remoteInstance
234+ self . api = api
214235 }
215236}
216237
@@ -260,6 +281,64 @@ private extension BloggingPromptsService {
260281 return Self . utcDateFormatter. date ( from: dateString)
261282 }
262283
284+ // MARK: Prompts
285+
286+ /// Fetches a number of blogging prompts for the specified site from the v3 endpoint.
287+ ///
288+ /// - Parameters:
289+ /// - number: The number of prompts to query. When not specified, this will default to remote implementation.
290+ /// - fromDate: When specified, this will fetch prompts from the given date. When not specified, this will default to remote implementation.
291+ /// - ignoresYear: When set to true, this will convert the date to a custom format that ignores the year part. Defaults to true.
292+ /// - forceYear: Forces the year value on the prompt's date to the specified value. Defaults to the current year.
293+ /// - completion: A closure that will be called when the fetch request completes.
294+ func fetchRemotePrompts( number: Int ? = nil ,
295+ fromDate: Date ? = nil ,
296+ ignoresYear: Bool = true ,
297+ forceYear: Int ? = nil ,
298+ completion: @escaping ( Result < [ BloggingPromptRemoteObject ] , Error > ) -> Void ) {
299+ let path = " wpcom/v3/sites/ \( siteID) /blogging-prompts "
300+ let requestParameter : [ String : AnyHashable ] = {
301+ var params = [ String: AnyHashable] ( )
302+
303+ if let number, number > 0 {
304+ params [ " per_page " ] = number
305+ }
306+
307+ if let fromDate {
308+ // convert to yyyy-MM-dd format in local timezone so users would see the same prompt throughout their day.
309+ var dateString = Self . localDateFormatter. string ( from: fromDate)
310+
311+ // when the year needs to be ignored, we'll transform the dateString to match the "--mm-dd" format.
312+ if ignoresYear, !dateString. isEmpty {
313+ dateString = " - " + dateString. dropFirst ( 4 )
314+ }
315+
316+ params [ " after " ] = dateString
317+ }
318+
319+ if let forceYear = forceYear ?? fromDate? . dateAndTimeComponents ( ) . year {
320+ params [ " force_year " ] = forceYear
321+ }
322+
323+ return params
324+ } ( )
325+
326+ api. GET ( path, parameters: requestParameter as [ String : AnyObject ] ) { result, _ in
327+ switch result {
328+ case . success( let responseObject) :
329+ do {
330+ let data = try JSONSerialization . data ( withJSONObject: responseObject, options: [ ] )
331+ let remotePrompts = try Self . jsonDecoder. decode ( [ BloggingPromptRemoteObject ] . self, from: data)
332+ completion ( . success( remotePrompts) )
333+ } catch {
334+ completion ( . failure( error) )
335+ }
336+ case . failure( let error) :
337+ completion ( . failure( error) )
338+ }
339+ }
340+ }
341+
263342 /// Loads local prompts based on the given parameters.
264343 ///
265344 /// - Parameters:
@@ -292,45 +371,64 @@ private extension BloggingPromptsService {
292371 /// - Parameters:
293372 /// - remotePrompts: An array containing prompts obtained from remote.
294373 /// - completion: Closure to be called after the process completes. Returns an array of prompts when successful.
295- func upsert( with remotePrompts: [ RemoteBloggingPrompt ] , completion: @escaping ( Result < Void , Error > ) -> Void ) {
374+ func upsert( with remotePrompts: [ BloggingPromptRemoteObject ] , completion: @escaping ( Result < Void , Error > ) -> Void ) {
296375 if remotePrompts. isEmpty {
297376 completion ( . success( ( ) ) )
298377 return
299378 }
300379
301- let remoteIDs = Set ( remotePrompts. map { Int32 ( $0. promptID) } )
302- let remotePromptsDictionary = remotePrompts. reduce ( into: [ Int32: RemoteBloggingPrompt] ( ) ) { partialResult, remotePrompt in
303- partialResult [ Int32 ( remotePrompt. promptID) ] = remotePrompt
380+ // incoming remote prompts should have unique dates.
381+ // fetch requests require the date to be `NSDate` specifically, hence the cast.
382+ let incomingDates = Set ( remotePrompts. map ( \. date) )
383+ let promptsByDate = remotePrompts. reduce ( into: [ Date: BloggingPromptRemoteObject] ( ) ) { partialResult, remotePrompt in
384+ partialResult [ remotePrompt. date] = remotePrompt
304385 }
305386
306- let predicate = NSPredicate ( format: " \( #keyPath( BloggingPrompt . siteID) ) = %@ AND \( #keyPath( BloggingPrompt . promptID) ) IN %@ " , siteID, remoteIDs)
387+ let predicate = NSPredicate ( format: " \( #keyPath( BloggingPrompt . siteID) ) = %@ AND \( #keyPath( BloggingPrompt . date) ) IN %@ " ,
388+ siteID,
389+ incomingDates. map { $0 as NSDate } )
307390 let fetchRequest = BloggingPrompt . fetchRequest ( )
308391 fetchRequest. predicate = predicate
309392
310393 contextManager. performAndSave ( { derivedContext in
311- var foundExistingIDs = [ Int32] ( )
394+ /// Try to overwrite prompts that have the same dates.
395+ ///
396+ /// Perf. notes: since we're at most updating 25 entries, it should be acceptable to update them one by one.
397+ /// However, if requirements change and we need to work through a larger data set, consider switching to
398+ /// a drop-and-replace strategy with `NSBatchDeleteRequest` as it's more performant.
399+ var updatedExistingDates = Set < Date > ( )
312400 let results = try derivedContext. fetch ( fetchRequest)
313401 results. forEach { prompt in
314- guard let remotePrompt = remotePromptsDictionary [ prompt. promptID ] else {
402+ guard let incoming = promptsByDate [ prompt. date ] else {
315403 return
316404 }
317405
318- foundExistingIDs. append ( prompt. promptID)
319- prompt. configure ( with: remotePrompt, for: self . siteID. int32Value)
406+ // ensure that there's only one prompt for each date.
407+ // if the prompt with this date has been updated before, then it's a duplicate. Let's delete it.
408+ if updatedExistingDates. contains ( prompt. date) {
409+ derivedContext. deleteObject ( prompt)
410+ return
411+ }
412+
413+ // otherwise, we can update the prompt matching the date with the incoming prompt.
414+ prompt. configure ( with: incoming, for: self . siteID. int32Value)
415+ updatedExistingDates. insert ( incoming. date)
320416 }
321417
322- // Insert new prompts
323- let newPromptIDs = remoteIDs . subtracting ( foundExistingIDs )
324- newPromptIDs . forEach { newPromptID in
325- guard let remotePrompt = remotePromptsDictionary [ newPromptID ] ,
418+ // process the remaining new prompts.
419+ let datesToInsert = incomingDates . subtracting ( updatedExistingDates )
420+ datesToInsert . forEach { date in
421+ guard let incoming = promptsByDate [ date ] ,
326422 let newPrompt = BloggingPrompt . newObject ( in: derivedContext) else {
327423 return
328424 }
329- newPrompt. configure ( with: remotePrompt , for: self . siteID. int32Value)
425+ newPrompt. configure ( with: incoming , for: self . siteID. int32Value)
330426 }
331427 } , completion: completion, on: . main)
332428 }
333429
430+ // MARK: Prompt Settings
431+
334432 /// Updates existing settings or creates new settings from the remote prompt settings.
335433 ///
336434 /// - Parameters:
0 commit comments