Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions MIGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@
This file documents changes in the data model. Please explain any changes to the
data model as well as any custom migrations.

## WordPress 140

@dvdchr 2022-05-13

- Created a new entity `BloggingPrompt` with:
- `promptID` (required, default `0`, `Int 32`)
- `siteID` (required, default `0`, `Int 32`)
- `text` (required, default empty string, `String`)
- `title` (required, default empty string, `String`)
- `content` (required, default empty string, `String`)
- `attribution` (required, default empty string, `String`)
- `date` (optional, no default, `Date`)
- `answered` (required, default `NO`, `Boolean`)
- `answerCount` (required, default `0`, `Int 32`)
- `displayAvatarURLs` (optional, no default, `Transformable` with type `[URL]`)

## WordPress 138

@dvdchr 2022-03-07
Expand Down
41 changes: 41 additions & 0 deletions WordPress/Classes/Models/BloggingPrompt+CoreDataClass.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Foundation
import CoreData
import WordPressKit

public class BloggingPrompt: NSManagedObject {

@nonobjc public class func fetchRequest() -> NSFetchRequest<BloggingPrompt> {
return NSFetchRequest<BloggingPrompt>(entityName: Self.classNameWithoutNamespaces())
}

@nonobjc public class func newObject(in context: NSManagedObjectContext) -> BloggingPrompt? {
return NSEntityDescription.insertNewObject(forEntityName: Self.classNameWithoutNamespaces(), into: context) as? BloggingPrompt
}

public override func awakeFromInsert() {
self.date = .init(timeIntervalSince1970: 0)
self.displayAvatarURLs = []
}

var promptAttribution: BloggingPromptsAttribution? {
BloggingPromptsAttribution(rawValue: attribution.lowercased())
}

/// Convenience method to map properties from `RemoteBloggingPrompt`.
///
/// - Parameters:
/// - remotePrompt: The remote prompt model to convert
/// - siteID: The ID of the site that the prompt is intended for
func configure(with remotePrompt: RemoteBloggingPrompt, for siteID: Int32) {
self.promptID = Int32(remotePrompt.promptID)
self.siteID = siteID
self.text = remotePrompt.text
self.title = remotePrompt.title
self.content = remotePrompt.content
self.attribution = remotePrompt.attribution
self.date = remotePrompt.date
self.answered = remotePrompt.answered
self.answerCount = Int32(remotePrompt.answeredUsersCount)
self.displayAvatarURLs = remotePrompt.answeredUserAvatarURLs
}
}
34 changes: 34 additions & 0 deletions WordPress/Classes/Models/BloggingPrompt+CoreDataProperties.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Foundation
import CoreData

extension BloggingPrompt {
/// The unique ID for the prompt, received from the server.
@NSManaged public var promptID: Int32

/// The site ID for the prompt.
@NSManaged public var siteID: Int32

/// The prompt content to be displayed at entry points.
@NSManaged public var text: String

/// Template title for the draft post.
@NSManaged public var title: String

/// Template content for the draft post.
@NSManaged public var content: String

/// The attribution source for the prompt.
@NSManaged public var attribution: String

/// The prompt date. Time information should be ignored.
@NSManaged public var date: Date

/// Whether the current user has answered the prompt in `siteID`.
@NSManaged public var answered: Bool

/// The number of users that has answered the prompt.
@NSManaged public var answerCount: Int32

/// Contains avatar URLs of some users that have answered the prompt.
@NSManaged public var displayAvatarURLs: [URL]
}
155 changes: 121 additions & 34 deletions WordPress/Classes/Services/BloggingPromptsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,25 @@ import CoreData
import WordPressKit

class BloggingPromptsService {
private let context: NSManagedObjectContext
private let contextManager: CoreDataStack
private let siteID: NSNumber
private let remote: BloggingPromptsServiceRemote
private let calendar: Calendar = .autoupdatingCurrent

private var defaultDate: Date {
calendar.date(byAdding: .day, value: -10, to: Date()) ?? Date()
/// A UTC date formatter that ignores time information.
private static var dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = .init(identifier: "en_US_POSIX")
formatter.timeZone = .init(secondsFromGMT: 0)
formatter.dateFormat = "yyyy-MM-dd"

return formatter
}()

/// Convenience computed variable that returns today's prompt from local store.
///
var localTodaysPrompt: BloggingPrompt? {
loadPrompts(from: Date(), number: 1).first
}

/// Fetches a number of blogging prompts starting from the specified date.
Expand All @@ -23,12 +35,18 @@ class BloggingPromptsService {
number: Int = 24,
success: @escaping ([BloggingPrompt]) -> Void,
failure: @escaping (Error?) -> Void) {
let fromDate = date ?? defaultDate
let fromDate = date ?? defaultStartDate
remote.fetchPrompts(for: siteID, number: number, fromDate: fromDate) { result in
switch result {
case .success(let remotePrompts):
// TODO: Upsert into CoreData once the CoreData model is available.
success(remotePrompts.map { BloggingPrompt(with: $0) })
self.upsert(with: remotePrompts) { innerResult in
if case .failure(let error) = innerResult {
failure(error)
return
}

success(self.loadPrompts(from: fromDate, number: number))
}
case .failure(let error):
failure(error)
}
Expand Down Expand Up @@ -59,45 +77,114 @@ class BloggingPromptsService {
fetchPrompts(from: fromDate, number: 11, success: success, failure: failure)
}

required init?(context: NSManagedObjectContext = ContextManager.shared.mainContext,
required init?(contextManager: CoreDataStack = ContextManager.shared,
remote: BloggingPromptsServiceRemote? = nil,
blog: Blog? = nil) {
guard let account = AccountService(managedObjectContext: context).defaultWordPressComAccount(),
guard let account = AccountService(managedObjectContext: contextManager.mainContext).defaultWordPressComAccount(),
let siteID = blog?.dotComID ?? account.primaryBlogID else {
return nil
}

self.context = context
self.contextManager = contextManager
self.siteID = siteID
self.remote = remote ?? .init(wordPressComRestApi: account.wordPressComRestV2Api)
}
}

// MARK: - Temporary model object

/// TODO: This is a temporary model to be replaced with Core Data model once the fields have all been finalized.
struct BloggingPrompt {
let promptID: Int
let text: String
let title: String // for post title
let content: String // for post content
let date: Date
let answered: Bool
let answerCount: Int
let displayAvatarURLs: [URL]
let attribution: String
}
// MARK: - Private Helpers

private extension BloggingPromptsService {

var defaultStartDate: Date {
calendar.date(byAdding: .day, value: -10, to: Date()) ?? Date()
}

/// Converts the given date to UTC and ignores the time information.
/// Example: Given `2022-05-01 03:00:00 UTC-5`, this should return `2022-05-01 00:00:00 UTC`.
///
/// - Parameter date: The date to convert.
/// - Returns: The UTC date without the time information.
func utcDateIgnoringTime(from date: Date) -> Date? {
let utcDateString = Self.dateFormatter.string(from: date)
return Self.dateFormatter.date(from: utcDateString)
}

/// Loads local prompts based on the given parameters.
///
/// - Parameters:
/// - date: When specified, only prompts from the specified date will be returned.
/// - number: The amount of prompts to return. Defaults to 24 when unspecified.
/// - Returns: An array of `BloggingPrompt` objects sorted descending by date.
func loadPrompts(from date: Date, number: Int) -> [BloggingPrompt] {
guard let utcDate = utcDateIgnoringTime(from: date) else {
DDLogError("Error converting date to UTC: \(date)")
return []
}

let fetchRequest = BloggingPrompt.fetchRequest()
fetchRequest.predicate = .init(format: "\(#keyPath(BloggingPrompt.siteID)) = %@ AND \(#keyPath(BloggingPrompt.date)) >= %@", siteID, utcDate as NSDate)
fetchRequest.fetchLimit = number
fetchRequest.sortDescriptors = [.init(key: #keyPath(BloggingPrompt.date), ascending: false)]

extension BloggingPrompt {
init(with remotePrompt: RemoteBloggingPrompt) {
promptID = remotePrompt.promptID
text = remotePrompt.text
title = remotePrompt.title
content = remotePrompt.content
date = remotePrompt.date
answered = remotePrompt.answered
answerCount = remotePrompt.answeredUsersCount
displayAvatarURLs = remotePrompt.answeredUserAvatarURLs
attribution = remotePrompt.attribution
return (try? self.contextManager.mainContext.fetch(fetchRequest)) ?? []
}

/// Find and update existing prompts, or insert new ones if they don't exist.
///
/// - Parameters:
/// - remotePrompts: An array containing prompts obtained from remote.
/// - completion: Closure to be called after the process completes. Returns an array of prompts when successful.
func upsert(with remotePrompts: [RemoteBloggingPrompt], completion: @escaping (Result<Void, Error>) -> Void) {
if remotePrompts.isEmpty {
completion(.success(()))
return
}

let remoteIDs = Set(remotePrompts.map { Int32($0.promptID) })
let remotePromptsDictionary = remotePrompts.reduce(into: [Int32: RemoteBloggingPrompt]()) { partialResult, remotePrompt in
partialResult[Int32(remotePrompt.promptID)] = remotePrompt
}

let predicate = NSPredicate(format: "\(#keyPath(BloggingPrompt.siteID)) = %@ AND \(#keyPath(BloggingPrompt.promptID)) IN %@", siteID, remoteIDs)
let fetchRequest = BloggingPrompt.fetchRequest()
fetchRequest.predicate = predicate

let derivedContext = contextManager.newDerivedContext()
derivedContext.perform {
do {
// Update existing prompts
var foundExistingIDs = [Int32]()
let results = try derivedContext.fetch(fetchRequest)
results.forEach { prompt in
guard let remotePrompt = remotePromptsDictionary[prompt.promptID] else {
return
}

foundExistingIDs.append(prompt.promptID)
prompt.configure(with: remotePrompt, for: self.siteID.int32Value)
}

// Insert new prompts
let newPromptIDs = remoteIDs.subtracting(foundExistingIDs)
newPromptIDs.forEach { newPromptID in
guard let remotePrompt = remotePromptsDictionary[newPromptID],
let newPrompt = BloggingPrompt.newObject(in: derivedContext) else {
return
}
newPrompt.configure(with: remotePrompt, for: self.siteID.int32Value)
}

self.contextManager.save(derivedContext) {
DispatchQueue.main.async {
completion(.success(()))
}
}

} catch let error {
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ class DashboardPromptsCardCell: UICollectionViewCell, Reusable {
return Constants.exampleAnswerCount
}

return prompt?.answerCount ?? 0
return Int(prompt?.answerCount ?? 0)
}()

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

if let promptAttribution = prompt?.attribution.lowercased(),
let attribution = BloggingPromptsAttribution(rawValue: promptAttribution) {
if let attribution = prompt?.promptAttribution {
attributionIcon.image = attribution.iconImage
attributionSourceLabel.attributedText = attribution.attributedText
containerStackView.addArrangedSubview(attributionStackView)
Expand Down Expand Up @@ -391,7 +390,7 @@ private extension DashboardPromptsCardCell {

@objc func answerButtonTapped() {
guard let blog = blog,
let prompt = prompt else {
let prompt = prompt else {
return
}

Expand Down
2 changes: 1 addition & 1 deletion WordPress/Classes/WordPress.xcdatamodeld/.xccurrentversion
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>WordPress 139.xcdatamodel</string>
<string>WordPress 140.xcdatamodel</string>
</dict>
</plist>
Loading