Skip to content

Commit 0166ff8

Browse files
authored
Merge pull request #18607 from wordpress-mobile/feature/18375-prompts-core-data
Blogging Prompts: Add Core Data model and adjust service
2 parents 45ab1df + c3ca003 commit 0166ff8

File tree

10 files changed

+1439
-51
lines changed

10 files changed

+1439
-51
lines changed

MIGRATIONS.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,22 @@
33
This file documents changes in the data model. Please explain any changes to the
44
data model as well as any custom migrations.
55

6+
## WordPress 140
7+
8+
@dvdchr 2022-05-13
9+
10+
- Created a new entity `BloggingPrompt` with:
11+
- `promptID` (required, default `0`, `Int 32`)
12+
- `siteID` (required, default `0`, `Int 32`)
13+
- `text` (required, default empty string, `String`)
14+
- `title` (required, default empty string, `String`)
15+
- `content` (required, default empty string, `String`)
16+
- `attribution` (required, default empty string, `String`)
17+
- `date` (optional, no default, `Date`)
18+
- `answered` (required, default `NO`, `Boolean`)
19+
- `answerCount` (required, default `0`, `Int 32`)
20+
- `displayAvatarURLs` (optional, no default, `Transformable` with type `[URL]`)
21+
622
## WordPress 138
723

824
@dvdchr 2022-03-07
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import Foundation
2+
import CoreData
3+
import WordPressKit
4+
5+
public class BloggingPrompt: NSManagedObject {
6+
7+
@nonobjc public class func fetchRequest() -> NSFetchRequest<BloggingPrompt> {
8+
return NSFetchRequest<BloggingPrompt>(entityName: Self.classNameWithoutNamespaces())
9+
}
10+
11+
@nonobjc public class func newObject(in context: NSManagedObjectContext) -> BloggingPrompt? {
12+
return NSEntityDescription.insertNewObject(forEntityName: Self.classNameWithoutNamespaces(), into: context) as? BloggingPrompt
13+
}
14+
15+
public override func awakeFromInsert() {
16+
self.date = .init(timeIntervalSince1970: 0)
17+
self.displayAvatarURLs = []
18+
}
19+
20+
var promptAttribution: BloggingPromptsAttribution? {
21+
BloggingPromptsAttribution(rawValue: attribution.lowercased())
22+
}
23+
24+
/// Convenience method to map properties from `RemoteBloggingPrompt`.
25+
///
26+
/// - Parameters:
27+
/// - remotePrompt: The remote prompt model to convert
28+
/// - siteID: The ID of the site that the prompt is intended for
29+
func configure(with remotePrompt: RemoteBloggingPrompt, for siteID: Int32) {
30+
self.promptID = Int32(remotePrompt.promptID)
31+
self.siteID = siteID
32+
self.text = remotePrompt.text
33+
self.title = remotePrompt.title
34+
self.content = remotePrompt.content
35+
self.attribution = remotePrompt.attribution
36+
self.date = remotePrompt.date
37+
self.answered = remotePrompt.answered
38+
self.answerCount = Int32(remotePrompt.answeredUsersCount)
39+
self.displayAvatarURLs = remotePrompt.answeredUserAvatarURLs
40+
}
41+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import Foundation
2+
import CoreData
3+
4+
extension BloggingPrompt {
5+
/// The unique ID for the prompt, received from the server.
6+
@NSManaged public var promptID: Int32
7+
8+
/// The site ID for the prompt.
9+
@NSManaged public var siteID: Int32
10+
11+
/// The prompt content to be displayed at entry points.
12+
@NSManaged public var text: String
13+
14+
/// Template title for the draft post.
15+
@NSManaged public var title: String
16+
17+
/// Template content for the draft post.
18+
@NSManaged public var content: String
19+
20+
/// The attribution source for the prompt.
21+
@NSManaged public var attribution: String
22+
23+
/// The prompt date. Time information should be ignored.
24+
@NSManaged public var date: Date
25+
26+
/// Whether the current user has answered the prompt in `siteID`.
27+
@NSManaged public var answered: Bool
28+
29+
/// The number of users that has answered the prompt.
30+
@NSManaged public var answerCount: Int32
31+
32+
/// Contains avatar URLs of some users that have answered the prompt.
33+
@NSManaged public var displayAvatarURLs: [URL]
34+
}

WordPress/Classes/Services/BloggingPromptsService.swift

Lines changed: 121 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,25 @@ import CoreData
22
import WordPressKit
33

44
class 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
}

WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ class DashboardPromptsCardCell: UICollectionViewCell, Reusable {
116116
return Constants.exampleAnswerCount
117117
}
118118

119-
return prompt?.answerCount ?? 0
119+
return Int(prompt?.answerCount ?? 0)
120120
}()
121121

122122
private var answerInfoText: String {
@@ -352,8 +352,7 @@ private extension DashboardPromptsCardCell {
352352
promptLabel.text = forExampleDisplay ? Strings.examplePrompt : prompt?.text.stringByDecodingXMLCharacters().trim()
353353
containerStackView.addArrangedSubview(promptTitleView)
354354

355-
if let promptAttribution = prompt?.attribution.lowercased(),
356-
let attribution = BloggingPromptsAttribution(rawValue: promptAttribution) {
355+
if let attribution = prompt?.promptAttribution {
357356
attributionIcon.image = attribution.iconImage
358357
attributionSourceLabel.attributedText = attribution.attributedText
359358
containerStackView.addArrangedSubview(attributionStackView)
@@ -391,7 +390,7 @@ private extension DashboardPromptsCardCell {
391390

392391
@objc func answerButtonTapped() {
393392
guard let blog = blog,
394-
let prompt = prompt else {
393+
let prompt = prompt else {
395394
return
396395
}
397396

WordPress/Classes/WordPress.xcdatamodeld/.xccurrentversion

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@
33
<plist version="1.0">
44
<dict>
55
<key>_XCCurrentVersionName</key>
6-
<string>WordPress 139.xcdatamodel</string>
6+
<string>WordPress 140.xcdatamodel</string>
77
</dict>
88
</plist>

0 commit comments

Comments
 (0)