Files
linkding-ios/Marks/Intents/BookmarkEntity.swift
T
Krishna KumarandClaude Opus 5 69556afc08
CI / build-and-deploy (push) Successful in 26s
CI / build-and-deploy (pull_request) Successful in 25s
Fix Spotlight indexing: route the entity URL to contentURL
Every bookmark failed to reach the Spotlight index. Each item died in
translation with "Provided object for field url is of class NSURL,
expected class: NSString", so on-device search and Ask Your Bookmarks
were retrieving from an index that was effectively empty.

Left to itself, App Intents indexes BookmarkEntity's `url` property under
the attribute set's own `url` key, which Spotlight's Cascade translator
types as NSString. Giving the property an explicit
`indexingKey: \.contentURL` sends it to a URL-typed field instead — and
contentURL is the right field for "where this content lives" regardless.
Keeping the property a URL rather than retyping it to String means
existing Shortcuts that read it are unaffected.

The attribute set now sets contentURL too, and the retrieval side reads
it back, so the round trip stays on one field.

Why it went unnoticed: translation happens after `indexAppEntities`
returns, so indexing logged success the whole time. Measured on the
simulator against the live library — 202 translation failures per launch
before, 0 after.

Adds SpotlightRetrievalTests, which indexes a bookmark and retrieves it
through the assistant's own path. Nothing weaker would have caught this,
since the failure was silent at every layer above the index.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
2026-07-27 19:12:25 -05:00

133 lines
4.6 KiB
Swift

import Foundation
import AppIntents
import CoreSpotlight
/// A Linkding bookmark exposed to Siri, Spotlight, Shortcuts, and the
/// Apple-Intelligence "Use Model" action.
///
/// Conforms to `IndexedEntity` so its title / description / tags become
/// Spotlight-searchable, which is what lets Siri answer "find my bookmark
/// about Swift concurrency". The identifier is the Linkding server id (`Int`),
/// which is stable across devices.
struct BookmarkEntity: AppEntity, IndexedEntity {
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Bookmark")
let id: Int
@Property(title: "Title")
var title: String
/// 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")
var host: String
@Property(title: "Description")
var details: String
@Property(title: "Tags")
var tags: [String]
@Property(title: "Unread")
var unread: Bool
/// AI-generated summary (when one has been produced). Exposed so the
/// Shortcuts "Use Model" action and Siri can reason over it.
@Property(title: "Summary")
var summary: String?
static let defaultQuery = BookmarkQuery()
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(
title: "\(title)",
subtitle: "\(host)",
image: .init(systemName: "bookmark.fill")
)
}
/// Rich Spotlight attributes so semantic search has real content to match.
var attributeSet: CSSearchableItemAttributeSet {
let attrs = CSSearchableItemAttributeSet(contentType: .url)
attrs.title = title
attrs.contentDescription = details.isEmpty ? summary : details
attrs.keywords = tags
attrs.contentURL = url
return attrs
}
}
extension BookmarkEntity {
init(from b: Bookmark) {
self.id = b.id
self.title = b.displayTitle
self.url = URL(string: b.url) ?? URL(string: "https://example.invalid")!
self.host = b.domain
self.details = b.contentExcerpt ?? ""
self.tags = b.tagNames
self.unread = b.unread
self.summary = b.aiSummary
}
/// Reconstruct a minimal `Bookmark` from the entity — enough for the
/// AI enrichment call, which only reads url/title/tags.
func makeBookmark() -> Bookmark {
Bookmark(
id: id,
url: url.absoluteString,
title: title,
description: details,
tagNames: tags,
dateAdded: Date(),
dateModified: Date(),
isArchived: false,
unread: unread,
shared: false,
websiteTitle: nil,
websiteDescription: nil,
faviconUrl: nil,
previewImageUrl: nil,
aiSummary: summary,
aiTags: nil
)
}
}
/// Looks bookmarks up by id and resolves free-text queries by hitting the
/// Linkding server live — the recommended path for data that lives on a
/// server and changes too often to pre-index.
struct BookmarkQuery: EntityStringQuery {
func entities(for identifiers: [Int]) async throws -> [BookmarkEntity] {
let api = try MarksIntent.api()
// Linkding has no batch-by-id endpoint; pull a page and filter.
let response = try await api.fetchBookmarks(limit: 200)
let wanted = Set(identifiers)
return response.results
.filter { wanted.contains($0.id) }
.map(BookmarkEntity.init(from:))
}
func entities(matching string: String) async throws -> [BookmarkEntity] {
let api = try MarksIntent.api()
let response = try await api.fetchBookmarks(search: string, limit: 25)
return response.results.map(BookmarkEntity.init(from:))
}
func suggestedEntities() async throws -> [BookmarkEntity] {
let api = try MarksIntent.api()
let response = try await api.fetchBookmarks(limit: 10)
return response.results.map(BookmarkEntity.init(from:))
}
}