From 313444710db4761cfb2caa64ed87973fd7dc3e12 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 4 Sep 2026 09:50:45 +1200 Subject: [PATCH 1/3] Reindex posts in Spotlight after every save Spotlight indexing ran only when a post moved to scheduled or published, so title and content edits to an existing post left a stale entry until the Posts list reloaded. Index the post after every successful save. --- WordPress/Classes/Services/PostCoordinator.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/WordPress/Classes/Services/PostCoordinator.swift b/WordPress/Classes/Services/PostCoordinator.swift index 924a3ff936f2..cadc50f39383 100644 --- a/WordPress/Classes/Services/PostCoordinator.swift +++ b/WordPress/Classes/Services/PostCoordinator.swift @@ -127,13 +127,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.shared.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)) From 8049c529378d7841c69f2918995188da19fbebb1 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 4 Sep 2026 09:51:02 +1200 Subject: [PATCH 2/3] Remove a post's Spotlight item when it is permanently deleted Trashing a post removed its Spotlight item, but permanently deleting one did not, so the stale result kept opening the app to nothing. Remove the item by identifier after the deletion succeeds. --- .../Tests/Services/PostCoordinatorTests.swift | 21 ++++++++++++++ .../Classes/Services/PostCoordinator.swift | 29 ++++++++++++++++--- .../Utility/Spotlight/SearchManager.swift | 11 ++++++- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/Tests/KeystoneTests/Tests/Services/PostCoordinatorTests.swift b/Tests/KeystoneTests/Tests/Services/PostCoordinatorTests.swift index 9b9521533247..54ad07b7d977 100644 --- a/Tests/KeystoneTests/Tests/Services/PostCoordinatorTests.swift +++ b/Tests/KeystoneTests/Tests/Services/PostCoordinatorTests.swift @@ -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 @@ -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 @@ -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 { @@ -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": [ diff --git a/WordPress/Classes/Services/PostCoordinator.swift b/WordPress/Classes/Services/PostCoordinator.swift index cadc50f39383..b8c499691714 100644 --- a/WordPress/Classes/Services/PostCoordinator.swift +++ b/WordPress/Classes/Services/PostCoordinator.swift @@ -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 { @@ -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 @@ -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() @@ -97,7 +108,7 @@ class PostCoordinator: NSObject { } else if post.status == .publish { notifyNewPostPublished() } - SearchManager.shared.indexItem(post) + searchManager.indexItem(post) AppRatingUtility.shared.incrementSignificantEvent() } @@ -130,7 +141,7 @@ class PostCoordinator: NSObject { // 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.shared.indexItem(post) + searchManager.indexItem(post) if previousStatus != post.status && post.isStatus(in: [.scheduled, .publish]) { if post.status == .scheduled { @@ -221,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) { @@ -896,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) @@ -910,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) diff --git a/WordPress/Classes/Utility/Spotlight/SearchManager.swift b/WordPress/Classes/Utility/Spotlight/SearchManager.swift index 979f7cd18c6b..5ee3cfc914c4 100644 --- a/WordPress/Classes/Utility/Spotlight/SearchManager.swift +++ b/WordPress/Classes/Utility/Spotlight/SearchManager.swift @@ -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 } From 439a68430c4b7883a5ff895570a23083d9520730 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 4 Sep 2026 09:51:38 +1200 Subject: [PATCH 3/3] Remove a site's Spotlight items when the site is removed Removing a site cleared Spotlight only when no account remained, so on a multi-site account the removed site's posts stayed searchable and opened the app to nothing. Delete the site's Spotlight domain when the user removes it and when account sync prunes it. The domain rule moves to Blog so posts and this cleanup share it. --- .../Swift/AbstractPost+Searchable.swift | 20 +++++++++++++------ WordPress/Classes/Services/BlogService.m | 2 ++ .../Utility/Spotlight/SearchManager.swift | 14 +++++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/WordPressData/Swift/AbstractPost+Searchable.swift b/Modules/Sources/WordPressData/Swift/AbstractPost+Searchable.swift index c1125fa8bd30..1e5043968fd9 100644 --- a/Modules/Sources/WordPressData/Swift/AbstractPost+Searchable.swift +++ b/Modules/Sources/WordPressData/Swift/AbstractPost+Searchable.swift @@ -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? { @@ -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 + } + } +} diff --git a/WordPress/Classes/Services/BlogService.m b/WordPress/Classes/Services/BlogService.m index b2fa70746ff6..8e974180bfe9 100644 --- a/WordPress/Classes/Services/BlogService.m +++ b/WordPress/Classes/Services/BlogService.m @@ -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; @@ -385,6 +386,7 @@ - (void)mergeBlogs:(NSArray *)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]; diff --git a/WordPress/Classes/Utility/Spotlight/SearchManager.swift b/WordPress/Classes/Utility/Spotlight/SearchManager.swift index 5ee3cfc914c4..0209ba57ef44 100644 --- a/WordPress/Classes/Utility/Spotlight/SearchManager.swift +++ b/WordPress/Classes/Utility/Spotlight/SearchManager.swift @@ -81,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: