Skip to content
Open
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
20 changes: 14 additions & 6 deletions Modules/Sources/WordPressData/Swift/AbstractPost+Searchable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,7 @@ extension AbstractPost: SearchableItemConvertable {
}

public var searchDomain: String? {
if let dotComID = blog.dotComID, dotComID.intValue > 0 {
return dotComID.stringValue
} else {
// This is a self-hosted site, set domain to the xmlrpc string
return blog.xmlrpc
}
return blog.searchDomain
}

public var searchTitle: String? {
Expand Down Expand Up @@ -78,3 +73,16 @@ fileprivate extension AbstractPost {
return "[\(AbstractPost.title(for: status))] \(title)"
}
}

extension Blog {
/// The Spotlight domain identifier shared by every item indexed for this
/// site, so the site's items can be removed together.
public var searchDomain: String? {
if let dotComID, dotComID.intValue > 0 {
return dotComID.stringValue
} else {
// This is a self-hosted site, set domain to the xmlrpc string
return xmlrpc
}
}
}
21 changes: 21 additions & 0 deletions Tests/KeystoneTests/Tests/Services/PostCoordinatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,13 @@ class PostCoordinatorTests: CoreDataTestCase {

/// Scenario: syncing changes to an existing draft that was permanently deleted.
func testSyncPermanentlyDeletedPost() async throws {
let searchManager = SearchManagerSpy()
coordinator = PostCoordinator(
mediaCoordinator: mediaCoordinator,
coreDataStack: contextManager,
searchManager: searchManager
)

// GIVEN a draft post that needs sync
let post = PostBuilder(mainContext, blog: blog).build()
post.postID = 974
Expand All @@ -330,6 +337,7 @@ class PostCoordinatorTests: CoreDataTestCase {

let revision1 = post.createRevision()
revision1.content = "content-b"
let searchableIdentifier = try XCTUnwrap(post.uniqueIdentifier)

// GIVEN a server where the post was deleted
stub(condition: isPath("/rest/v1.2/sites/80511/posts/974")) { _ in
Expand Down Expand Up @@ -357,6 +365,7 @@ class PostCoordinatorTests: CoreDataTestCase {

// THEN post got deleted from the database
XCTAssertNil(post.managedObjectContext)
XCTAssertEqual(searchManager.deletedIdentifiers, [searchableIdentifier])
}

func testPauseSyncing() async throws {
Expand Down Expand Up @@ -615,6 +624,18 @@ class PostCoordinatorTests: CoreDataTestCase {
}
}

private final class SearchManagerSpy: SearchManaging {
private(set) var deletedIdentifiers: [String] = []

func indexItem(_ item: SearchableItemConvertable) {}

func deleteSearchableItem(_ item: SearchableItemConvertable) {}

func deleteSearchableItems(withIdentifiers identifiers: [String]) {
deletedIdentifiers.append(contentsOf: identifiers)
}
}

private let mediaResponse = """
{
"media": [
Expand Down
2 changes: 2 additions & 0 deletions WordPress/Classes/Services/BlogService.m
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ - (void)removeBlog:(Blog *)blog
[self unscheduleBloggingRemindersFor:blog];
[self removeWordPressApiCachedDataForBlog:blog];
[self evictWordPressClientForBlog:blog];
[[SearchManager shared] deleteSearchableItemsForBlog:blog];

WPAccount *account = blog.account;

Expand Down Expand Up @@ -385,6 +386,7 @@ - (void)mergeBlogs:(NSArray<RemoteBlog *> *)blogs withAccountID:(NSManagedObject
if ([toDelete containsObject:blog.dotComID]) {
[self unscheduleBloggingRemindersFor:blog];
[self evictWordPressClientForBlog:blog];
[[SearchManager shared] deleteSearchableItemsForBlog:blog];
// Consider switching this to a call to removeBlog in the future
// to consolidate behaviour @frosty
[context deleteObject:blog];
Expand Down
33 changes: 29 additions & 4 deletions WordPress/Classes/Services/PostCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ protocol PostCoordinatorDelegate: AnyObject {
func postCoordinator(_ postCoordinator: PostCoordinator, promptForPasswordForBlog blog: Blog)
}

protocol SearchManaging {
func indexItem(_ item: SearchableItemConvertable)
func deleteSearchableItem(_ item: SearchableItemConvertable)
func deleteSearchableItems(withIdentifiers identifiers: [String])
}

extension SearchManager: SearchManaging {}

class PostCoordinator: NSObject {

enum SavingError: Error, LocalizedError, CustomNSError {
Expand Down Expand Up @@ -56,6 +64,7 @@ class PostCoordinator: NSObject {

private let mediaCoordinator: MediaCoordinator
private let actionDispatcherFacade: ActionDispatcherFacade
private let searchManager: SearchManaging

/// The initial sync retry delay. By default, 15 seconds.
var syncRetryDelay: TimeInterval = 15
Expand All @@ -64,10 +73,12 @@ class PostCoordinator: NSObject {

init(mediaCoordinator: MediaCoordinator? = nil,
actionDispatcherFacade: ActionDispatcherFacade = ActionDispatcherFacade(),
coreDataStack: CoreDataStackSwift = ContextManager.shared) {
coreDataStack: CoreDataStackSwift = ContextManager.shared,
searchManager: SearchManaging = SearchManager.shared) {
self.coreDataStack = coreDataStack
self.mediaCoordinator = mediaCoordinator ?? MediaCoordinator.shared
self.actionDispatcherFacade = actionDispatcherFacade
self.searchManager = searchManager

super.init()

Expand Down Expand Up @@ -97,7 +108,7 @@ class PostCoordinator: NSObject {
} else if post.status == .publish {
notifyNewPostPublished()
}
SearchManager.shared.indexItem(post)
searchManager.indexItem(post)
AppRatingUtility.shared.incrementSignificantEvent()
}

Expand Down Expand Up @@ -127,13 +138,17 @@ class PostCoordinator: NSObject {
let repository = PostRepository(coreDataStack: coreDataStack)
try await repository.save(post, changes: changes)

// Keep Spotlight current for every save, not only for the
// transition to scheduled or published, so edits to the title
// or content of an existing post reach the index.
searchManager.indexItem(post)

if previousStatus != post.status && post.isStatus(in: [.scheduled, .publish]) {
if post.status == .scheduled {
notifyNewPostScheduled()
} else if post.status == .publish {
notifyNewPostPublished()
}
SearchManager.shared.indexItem(post)
AppRatingUtility.shared.incrementSignificantEvent()
}
show(PostCoordinator.makeUploadSuccessNotice(for: post, previousStatus: previousStatus))
Expand Down Expand Up @@ -217,9 +232,13 @@ class PostCoordinator: NSObject {
}

private func handlePermanentlyDeleted(_ post: AbstractPost) {
let searchableIdentifier = post.uniqueIdentifier
let context = coreDataStack.mainContext
context.deleteObject(post)
ContextManager.shared.saveContextAndWait(context)
if let searchableIdentifier {
searchManager.deleteSearchableItems(withIdentifiers: [searchableIdentifier])
}
}

private func show(_ notice: Notice) {
Expand Down Expand Up @@ -892,7 +911,7 @@ class PostCoordinator: NSObject {
try await PostRepository(coreDataStack: coreDataStack).trash(post)

MediaCoordinator.shared.cancelUploadOfAllMedia(for: post)
SearchManager.shared.deleteSearchableItem(post)
searchManager.deleteSearchableItem(post)
} catch {
trackError(error, operation: "post-trash", post: post)
handleError(error, for: post)
Expand All @@ -906,8 +925,14 @@ class PostCoordinator: NSObject {
setUpdating(true, for: post)
defer { setUpdating(false, for: post) }

// Capture the identifier first: the managed object is gone once the
// deletion succeeds.
let searchableIdentifier = post.uniqueIdentifier
do {
try await PostRepository(coreDataStack: coreDataStack).delete(post)
if let searchableIdentifier {
searchManager.deleteSearchableItems(withIdentifiers: [searchableIdentifier])
}
} catch {
trackError(error, operation: "post-delete", post: post)
handleError(error, for: post)
Expand Down
25 changes: 24 additions & 1 deletion WordPress/Classes/Utility/Spotlight/SearchManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,16 @@ import WordPressData
/// - items: items to remove
///
@objc func deleteSearchableItems(_ items: [SearchableItemConvertable]) {
let ids = items.map({ $0.uniqueIdentifier }).compactMap({ $0 })
deleteSearchableItems(withIdentifiers: items.compactMap { $0.uniqueIdentifier })
}

/// Remove items from the on-device index by their unique identifiers.
/// Use this when the item's managed object is already gone.
///
/// - Parameters:
/// - ids: unique identifiers of the items to remove
///
@objc func deleteSearchableItems(withIdentifiers ids: [String]) {
guard !ids.isEmpty else {
return
}
Expand All @@ -72,6 +81,20 @@ import WordPressData
})
}

/// Removes every item indexed for a site. Call it before the site is
/// deleted from Core Data, while its domain is still available.
///
/// - Parameters:
/// - blog: the site being removed
///
@objc(deleteSearchableItemsForBlog:)
func deleteSearchableItems(for blog: Blog) {
guard let domain = blog.searchDomain else {
return
}
deleteAllSearchableItemsFromDomain(domain)
}

/// Removes all items with the given domain identifier from the on-device index
///
/// - Parameters:
Expand Down
Loading