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 @Property(title: "URL") 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.url = 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:)) } }