Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

De-duplicate blogs after syncing #12377

Merged
merged 6 commits into from
Aug 23, 2019
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
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* Stats Periods: Fixed an issue that made the Post stats title button unable.
* Adds a Publish Now action to posts in the posts list.
* Stats Periods: Fixed a bug that affected the header date when the site and the device timezones were different.
* My Sites: Fixed a problem where some sites would appear duplicated.

13.0
-----
Expand Down
3 changes: 2 additions & 1 deletion WordPress/Classes/Models/Blog.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

NS_ASSUME_NONNULL_BEGIN

@class AbstractPost;
@class BlogSettings;
@class WPAccount;
@class WordPressComRestApi;
Expand Down Expand Up @@ -86,7 +87,7 @@ typedef NS_ENUM(NSInteger, SiteVisibility) {
@property (nonatomic, strong, readwrite, nullable) NSString *apiKey;
@property (nonatomic, strong, readwrite, nullable) NSNumber *hasOlderPosts;
@property (nonatomic, strong, readwrite, nullable) NSNumber *hasOlderPages;
@property (nonatomic, strong, readwrite, nullable) NSSet *posts;
@property (nonatomic, strong, readwrite, nullable) NSSet<AbstractPost *> *posts;
@property (nonatomic, strong, readwrite, nullable) NSSet *categories;
@property (nonatomic, strong, readwrite, nullable) NSSet *tags;
@property (nonatomic, strong, readwrite, nullable) NSSet *comments;
Expand Down
61 changes: 61 additions & 0 deletions WordPress/Classes/Services/BlogService+Deduplicate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Foundation

extension BlogService {
/// Removes any duplicate blogs in the given account
///
/// We consider a blog to be a duplicate of another if they have the same dotComID.
/// For each group of duplicate blogs, this will delete all but one, giving preference to
/// blogs that have local drafts.
///
/// If there's more than one blog in each group with local drafts, those will be reassigned
/// to the remaining blog.
///
@objc(deduplicateBlogsForAccount:)
func deduplicateBlogs(for account: WPAccount) {
// Group all the account blogs by ID so it's easier to find duplicates
let blogsById = Dictionary(grouping: account.blogs, by: { $0.dotComID?.intValue ?? 0 })
// For any group with more than one blog, remove duplicates
for (blogID, group) in blogsById where group.count > 1 {
assert(blogID > 0, "There should not be a Blog without ID if it has an account")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't this crash in cases where a XML-RPC blog would get duplicated? They will have a dotComID of nil, which would get coalesced to 0 in the line above and then trigger this assert.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, but this is based on Account anyway, so they have to be REST blogs anyway. Nevermind.

guard blogID > 0 else {
DDLogError("Found one or more WordPress.com blogs without ID, skipping de-duplication")
continue
}
DDLogWarn("Found \(group.count - 1) duplicates for blog with ID \(blogID)")
deduplicate(group: group)
}
}

private func deduplicate(group: [Blog]) {
// If there's a blog with local drafts, we'll preserve that one, otherwise we pick up the first
// since we don't really care which blog to pick
let candidateIndex = group.firstIndex(where: { !localDrafts(for: $0).isEmpty }) ?? 0
let candidate = group[candidateIndex]

// We look through every other blog
for (index, blog) in group.enumerated() where index != candidateIndex {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had no idea you could do a where together with for... in! That's so cool!

// If there are other blogs with local drafts, we reassing them to the blog that
// is not going to be deleted
for draft in localDrafts(for: blog) {
DDLogInfo("Migrating local draft \(draft.postTitle ?? "<Untitled>") to de-duplicated blog")
draft.blog = candidate
}
// Once the drafts are moved (if any), we can safely delete the duplicate
DDLogInfo("Deleting duplicate blog \(blog.logDescription())")
managedObjectContext.delete(blog)
}
}

private func localDrafts(for blog: Blog) -> [AbstractPost] {
// The original predicate from PostService.countPostsWithoutRemote() was:
// "postID = NULL OR postID <= 0"
// Swift optionals make things a bit more verbose, but this should be equivalent
return blog.posts?.filter({ (post) -> Bool in
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a isDraft property on AbstractPost you might be able to use here, though it uses different logic — not sure which is more applicable.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I follow the same logic we use to show an alert warning that you have local drafts when signing out

if let postID = post.postID?.intValue,
postID > 0 {
return false
}
return true
}) ?? []
}
}
11 changes: 11 additions & 0 deletions WordPress/Classes/Services/BlogService.m
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,17 @@ - (void)mergeBlogs:(NSArray<RemoteBlog *> *)blogs withAccount:(WPAccount *)accou
[self updateBlogWithRemoteBlog:remoteBlog account:account];
}

/*
Sometimes bad things happen and blogs get duplicated. 👭
Hopefully we'll fix all the causes and this should never happen again 🤞🤞🤞
But even if it never happens again, it has already happened so we need to clean up. 🧹
Otherwise, users would have to reinstall the app to get rid of duplicates 🙅‍♀️

More context here:
https://github.com/wordpress-mobile/WordPress-iOS/issues/7886#issuecomment-524221031
*/
[self deduplicateBlogsForAccount:account];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we do it on every syncBlogsForAccount: call? Seems like once during an app startup should be enough?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't really know for sure how blogs get duplicated, and in any case I think it belongs conceptually in the sync method: when you call sync you expect the local list to reflect what's on the server.

The implementation will bail early if it detects no duplicates, so it shouldn't be too much overhead.

I wouldn't mind revisiting this once we have all the other fixes in place though.


[[ContextManager sharedInstance] saveContext:self.managedObjectContext];

if (completion != nil) {
Expand Down
8 changes: 8 additions & 0 deletions WordPress/WordPress.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1692,6 +1692,8 @@
E183ECB216B2179B00C2EB11 /* Twitter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CC24E5F21577DFF400A6D5B5 /* Twitter.framework */; };
E185042F1EE6ABD9005C234C /* Restorer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E185042E1EE6ABD9005C234C /* Restorer.swift */; };
E185474E1DED8D8800D875D7 /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E185474D1DED8D8800D875D7 /* UserNotifications.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
E18549D9230EED73003C620E /* BlogService+Deduplicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18549D8230EED73003C620E /* BlogService+Deduplicate.swift */; };
E18549DB230FBFEF003C620E /* BlogServiceDeduplicationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18549DA230FBFEF003C620E /* BlogServiceDeduplicationTests.swift */; };
E1928B2E1F8369F100E076C8 /* WebViewAuthenticatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1928B2D1F8369F100E076C8 /* WebViewAuthenticatorTests.swift */; };
E192E78C22EF453C008D725D /* WordPress-87-88.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = E192E78B22EF453C008D725D /* WordPress-87-88.xcmappingmodel */; };
E19B17AE1E5C6944007517C6 /* BasePost.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19B17AD1E5C6944007517C6 /* BasePost.swift */; };
Expand Down Expand Up @@ -3945,6 +3947,8 @@
E1823E6B1E42231C00C19F53 /* UIEdgeInsets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIEdgeInsets.swift; sourceTree = "<group>"; };
E185042E1EE6ABD9005C234C /* Restorer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Restorer.swift; sourceTree = "<group>"; };
E185474D1DED8D8800D875D7 /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = System/Library/Frameworks/UserNotifications.framework; sourceTree = SDKROOT; };
E18549D8230EED73003C620E /* BlogService+Deduplicate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlogService+Deduplicate.swift"; sourceTree = "<group>"; };
E18549DA230FBFEF003C620E /* BlogServiceDeduplicationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogServiceDeduplicationTests.swift; sourceTree = "<group>"; };
E1863F9A1355E0AB0031BBC8 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/Localizable.strings; sourceTree = "<group>"; };
E1874BFE161C5DBC0058BDC4 /* WordPress 7.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 7.xcdatamodel"; sourceTree = "<group>"; };
E18D8AE21397C51A00000861 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -6843,6 +6847,7 @@
93C1148318EDF6E100DAC95C /* BlogService.h */,
93C1148418EDF6E100DAC95C /* BlogService.m */,
9A341E5221997A1E0036662E /* BlogService+BlogAuthors.swift */,
E18549D8230EED73003C620E /* BlogService+Deduplicate.swift */,
9A2D0B22225CB92B009E585F /* BlogService+JetpackConvenience.swift */,
7E7BEF7222E1DD27009A880D /* EditorSettingsService.swift */,
E1556CF0193F6FE900FC52EA /* CommentService.h */,
Expand Down Expand Up @@ -7873,6 +7878,7 @@
9363113E19FA996700B0C739 /* AccountServiceTests.swift */,
E150520B16CAC5C400D3DDDC /* BlogJetpackTest.m */,
930FD0A519882742000CC81D /* BlogServiceTest.m */,
E18549DA230FBFEF003C620E /* BlogServiceDeduplicationTests.swift */,
74585B981F0D58F300E7E667 /* DomainsServiceTests.swift */,
B5772AC51C9C84900031F97E /* GravatarServiceTests.swift */,
59A9AB391B4C3ECD00A433DC /* LocalCoreDataServiceTests.m */,
Expand Down Expand Up @@ -11177,6 +11183,7 @@
7E3E7A5320E44B260075D159 /* SubjectContentStyles.swift in Sources */,
5D42A3DF175E7452005CFF05 /* AbstractPost.m in Sources */,
986C908422319EFF00FC31E1 /* PostStatsTableViewController.swift in Sources */,
E18549D9230EED73003C620E /* BlogService+Deduplicate.swift in Sources */,
FACB36F11C5C2BF800C6DF4E /* ThemeWebNavigationDelegate.swift in Sources */,
E1F47D4D1FE0290C00C1D44E /* PluginListCell.swift in Sources */,
D829C33B21B12EFE00B09F12 /* UIView+Borders.swift in Sources */,
Expand Down Expand Up @@ -11687,6 +11694,7 @@
D816B8D12112D5960052CE4D /* DefaultNewsManagerTests.swift in Sources */,
748437EE1F1D4A7300E8DDAF /* RichContentFormatterTests.swift in Sources */,
D88A649E208D82D2008AE9BC /* XCTestCase+Wait.swift in Sources */,
E18549DB230FBFEF003C620E /* BlogServiceDeduplicationTests.swift in Sources */,
400A2C8F2217AD7F000A8A59 /* ClicksStatsRecordValueTests.swift in Sources */,
7320C8BD2190C9FC0082FED5 /* UITextView+SummaryTests.swift in Sources */,
7E53AB0420FE6681005796FE /* ActivityContentRouterTests.swift in Sources */,
Expand Down
176 changes: 176 additions & 0 deletions WordPress/WordPressTest/BlogServiceDeduplicationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import CoreData
import XCTest
@testable import WordPress

class BlogServiceDeduplicationTests: XCTestCase {
var contextManager: TestContextManager!
var blogService: BlogService!
var context: NSManagedObjectContext {
return contextManager.mainContext
}

override func setUp() {
super.setUp()

contextManager = TestContextManager()
blogService = BlogService(managedObjectContext: contextManager.mainContext)
}

override func tearDown() {
super.tearDown()

ContextManager.overrideSharedInstance(nil)
contextManager.mainContext.reset()
contextManager = nil
blogService = nil
}

func testDeduplicationDoesNothingWhenNoDuplicates() {
let account = createAccount()
let blog1 = createBlog(id: 1, url: "blog1.example.com", account: account)
let blog2 = createBlog(id: 2, url: "blog2.example.com", account: account)
createDraft(title: "Post 1 in Blog 2", blog: blog2, id: 1)
createDraft(title: "Draft 2 in Blog 2", blog: blog2)
let blog3 = createBlog(id: 3, url: "blog3.example.com", account: account)

XCTAssertEqual(account.blogs.count, 3)
XCTAssertEqual(blog1.posts?.count, 0)
XCTAssertEqual(blog2.posts?.count, 2)
XCTAssertEqual(blog3.posts?.count, 0)

deduplicateAndSave(account)

XCTAssertEqual(account.blogs.count, 3)
XCTAssertEqual(blog1.posts?.count, 0)
XCTAssertEqual(blog2.posts?.count, 2)
XCTAssertEqual(blog3.posts?.count, 0)
}

func testDeduplicationRemovesDuplicateBlogsWithoutDrafts() {
let account = createAccount()
createBlog(id: 1, url: "blog1.example.com", account: account)
createBlog(id: 2, url: "blog2.example.com", account: account)
createBlog(id: 2, url: "blog2.example.com", account: account)

XCTAssertEqual(account.blogs.count, 3)

deduplicateAndSave(account)

XCTAssertEqual(account.blogs.count, 2)
}

func testDeduplicationPrefersCandidateWithLocalDrafts() {
let account = createAccount()

let blog1 = createBlog(id: 1, url: "blog1.example.com", account: account)

let blog2a = createBlog(id: 2, url: "blog2.example.com", account: account)
createDraft(title: "Post 1 in Blog 2", blog: blog2a, id: 1)
createDraft(title: "Draft 2 in Blog 2", blog: blog2a)
let blog2b = createBlog(id: 2, url: "blog2.example.com", account: account)

let blog3a = createBlog(id: 3, url: "blog3.example.com", account: account)
let blog3b = createBlog(id: 3, url: "blog3.example.com", account: account)
createDraft(title: "Post 1 in Blog 3", blog: blog3b, id: 1)
createDraft(title: "Draft 2 in Blog 3", blog: blog3b)

XCTAssertEqual(account.blogs.count, 5)
XCTAssertEqual(blog1.posts?.count, 0)
XCTAssertEqual(blog2a.posts?.count, 2)
XCTAssertEqual(blog2b.posts?.count, 0)
XCTAssertEqual(blog3a.posts?.count, 0)
XCTAssertEqual(blog3b.posts?.count, 2)

deduplicateAndSave(account)

XCTAssertEqual(account.blogs.count, 3)
XCTAssertEqual(account.blogs, Set(arrayLiteral: blog1, blog2a, blog3b))

XCTAssertFalse(isDeleted(blog1))
XCTAssertFalse(isDeleted(blog2a))
XCTAssertTrue(isDeleted(blog2b))
XCTAssertTrue(isDeleted(blog3a))
XCTAssertFalse(isDeleted(blog3b))

XCTAssertEqual(blog1.posts?.count, 0)
XCTAssertEqual(blog2a.posts?.count, 2)
XCTAssertEqual(blog3b.posts?.count, 2)
}

func testDeduplicationMigratesLocalDrafts() {
let account = createAccount()

let blog1 = createBlog(id: 1, url: "blog1.example.com", account: account)

let blog2a = createBlog(id: 2, url: "blog2.example.com", account: account)
createDraft(title: "Post 1 in Blog 2", blog: blog2a, id: 1)
createDraft(title: "Draft 2 in Blog 2", blog: blog2a)
let blog2b = createBlog(id: 2, url: "blog2.example.com", account: account)
createDraft(title: "Post 1 in Blog 2", blog: blog2b, id: 1)
createDraft(title: "Post 3 in Blog 2", blog: blog2b, id: 3)
createDraft(title: "Draft 4 in Blog 2", blog: blog2b)

XCTAssertEqual(account.blogs.count, 3)
XCTAssertEqual(blog1.posts?.count, 0)
XCTAssertEqual(blog2a.posts?.count, 2)
XCTAssertEqual(blog2b.posts?.count, 3)

deduplicateAndSave(account)

XCTAssertEqual(account.blogs.count, 2)

XCTAssertFalse(isDeleted(blog1))
// We don't care which one is deleted, but one of them should be
XCTAssertTrue(isDeleted(blog2a) != isDeleted(blog2b), "Exactly one copy of Blog 2 should have been deleted")

XCTAssertEqual(blog1.posts?.count, 0)
guard let blog2Final = account.blogs.first(where: { $0.dotComID == 2 }) else {
return XCTFail("There should be one blog with ID = 2")
}
XCTAssertTrue(findPost(title: "Post 1 in Blog 2", in: blog2Final))
XCTAssertTrue(findPost(title: "Draft 2 in Blog 2", in: blog2Final))
XCTAssertTrue(findPost(title: "Draft 4 in Blog 2", in: blog2Final))
}
}

private extension BlogServiceDeduplicationTests {
func deduplicateAndSave(_ account: WPAccount) {
blogService.deduplicateBlogs(for: account)
contextManager.saveContextAndWait(context)
}

func isDeleted(_ object: NSManagedObject) -> Bool {
return object.isDeleted || object.managedObjectContext == nil
}

func findPost(title: String, in blog: Blog) -> Bool {
return blog.posts?.contains(where: { (post) in
post.postTitle?.contains(title) ?? false
}) ?? false
}

@discardableResult
func createAccount() -> WPAccount {
let accountService = AccountService(managedObjectContext: context)
return accountService.createOrUpdateAccount(withUsername: "twoface", authToken: "twotoken")
}

@discardableResult
func createBlog(id: Int, url: String, account: WPAccount) -> Blog {
let blog = NSEntityDescription.insertNewObject(forEntityName: "Blog", into: context) as! Blog
blog.dotComID = id as NSNumber
blog.url = url
blog.xmlrpc = url
blog.account = account
return blog
}

@discardableResult
func createDraft(title: String, blog: Blog, id: Int? = nil) -> AbstractPost {
let post = NSEntityDescription.insertNewObject(forEntityName: "Post", into: context) as! Post
post.postTitle = title
post.blog = blog
post.postID = id.map({ $0 as NSNumber })
return post
}
}