diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index c90b335..9974c22 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; }; 50F3BED92EBA34F863C9F8A0 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7510EB352E624C7C9656EA33 /* LibraryView.swift */; }; 55CDFFAB5530D08861F85363 /* LibraryGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */; }; + 59E46C9653C9033F5BB1CEC7 /* SpotlightRetrievalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2526E22D8EC5453358F0FCF9 /* SpotlightRetrievalTests.swift */; }; 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; }; 5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; }; 66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */; }; @@ -136,6 +137,7 @@ 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = MarksWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 240DBB87940F8D255A812EB2 /* BookmarkActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkActions.swift; sourceTree = ""; }; + 2526E22D8EC5453358F0FCF9 /* SpotlightRetrievalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightRetrievalTests.swift; sourceTree = ""; }; 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightBookmarkSearch.swift; sourceTree = ""; }; 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentPodcastsWidget.swift; sourceTree = ""; }; 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; }; @@ -244,6 +246,7 @@ isa = PBXGroup; children = ( 7623601C25E481DF58371F2A /* AppIntentsTests.swift */, + 2526E22D8EC5453358F0FCF9 /* SpotlightRetrievalTests.swift */, ); path = MarksTests; sourceTree = ""; @@ -528,6 +531,7 @@ buildActionMask = 2147483647; files = ( FD656A44CEE8AE28B136AD85 /* AppIntentsTests.swift in Sources */, + 59E46C9653C9033F5BB1CEC7 /* SpotlightRetrievalTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Marks/Intents/BookmarkEntity.swift b/Marks/Intents/BookmarkEntity.swift index 94f9164..f619805 100644 --- a/Marks/Intents/BookmarkEntity.swift +++ b/Marks/Intents/BookmarkEntity.swift @@ -17,7 +17,17 @@ struct BookmarkEntity: AppEntity, IndexedEntity { @Property(title: "Title") var title: String - @Property(title: "URL") + /// The explicit `indexingKey` is load-bearing. Left to itself App Intents + /// indexes this property under the attribute set's own `url` key, and + /// Spotlight's Cascade translator types that field as NSString — so every + /// item failed with "Provided object for field url is of class NSURL, + /// expected class: NSString" and nothing reached the index. `contentURL` + /// is URL-typed there, and is the right field for "where this lives" + /// anyway. Routing it there keeps the property a URL for Shortcuts. + /// + /// The failure is silent: translation happens after `indexAppEntities` + /// returns, so indexing logs success either way. + @Property(title: "URL", indexingKey: \.contentURL) var url: URL @Property(title: "Website") @@ -53,7 +63,7 @@ struct BookmarkEntity: AppEntity, IndexedEntity { attrs.title = title attrs.contentDescription = details.isEmpty ? summary : details attrs.keywords = tags - attrs.url = url + attrs.contentURL = url return attrs } } diff --git a/Marks/Services/SpotlightBookmarkSearch.swift b/Marks/Services/SpotlightBookmarkSearch.swift index bb55a6d..2e50766 100644 --- a/Marks/Services/SpotlightBookmarkSearch.swift +++ b/Marks/Services/SpotlightBookmarkSearch.swift @@ -24,7 +24,7 @@ enum SpotlightBookmarkSearch { Log.spotlight.debug("Search query=\(rawQuery, privacy: .public) predicate=\(queryString, privacy: .public)") let context = CSSearchQueryContext() - context.fetchAttributes = ["title", "contentDescription", "keywords", "url"] + context.fetchAttributes = ["title", "contentDescription", "keywords", "contentURL"] let query = CSSearchQuery(queryString: queryString, queryContext: context) var out: [RetrievedBookmark] = [] @@ -33,9 +33,9 @@ enum SpotlightBookmarkSearch { let a = result.item.attributeSet out.append(RetrievedBookmark( title: a.title ?? "Untitled", - host: a.url?.host() ?? "", + host: a.contentURL?.host() ?? "", description: a.contentDescription ?? "", - url: a.url?.absoluteString ?? "" + url: a.contentURL?.absoluteString ?? "" )) if out.count >= limit { break } } diff --git a/MarksTests/SpotlightRetrievalTests.swift b/MarksTests/SpotlightRetrievalTests.swift new file mode 100644 index 0000000..9413564 --- /dev/null +++ b/MarksTests/SpotlightRetrievalTests.swift @@ -0,0 +1,40 @@ +import Testing +import CoreSpotlight +@testable import Marks + +/// End-to-end guard for the whole Spotlight path: index a bookmark, then +/// retrieve it the way the on-device assistant does. +/// +/// Worth keeping. The bug this was written for made every item fail to +/// translate into the index while `indexAppEntities` still reported success, +/// so nothing short of a round trip would have caught it. +struct SpotlightRetrievalTests { + @Test func indexedBookmarkIsRetrievable() async throws { + let b = Bookmark( + id: 999_001, + url: "https://github.com/example/zqxjkltest", + title: "Zqxjkltest concurrency notes", + description: "A distinctive probe document about zqxjkltest.", + tagNames: ["zqxjkltest"], + dateAdded: Date(), dateModified: Date(), + isArchived: false, unread: false, shared: false, + websiteTitle: nil, websiteDescription: nil, + faviconUrl: nil, previewImageUrl: nil, + aiSummary: nil, aiTags: nil + ) + await SpotlightIndexer.index([b]) + var hits: [RetrievedBookmark] = [] + for _ in 0..<20 { + try? await Task.sleep(for: .milliseconds(700)) + hits = await SpotlightBookmarkSearch.run(query: "zqxjkltest", limit: 5) + if !hits.isEmpty { break } + } + await SpotlightIndexer.remove(ids: [999_001]) + #expect(!hits.isEmpty, "Spotlight returned no hits for an indexed bookmark") + let hit = try #require(hits.first) + #expect(hit.title.contains("Zqxjkltest")) + #expect(hit.url == "https://github.com/example/zqxjkltest", + "url did not survive the round trip: \(hit.url)") + #expect(hit.host == "github.com", "host was \(hit.host)") + } +}