Files
links/mobile/Heygo/Utils/SpotlightSearchManager.swift
T
2025-08-16 16:51:20 +10:00

292 lines
10 KiB
Swift

import Foundation
import CoreSpotlight
import CoreData
import UIKit
import UniformTypeIdentifiers
class SpotlightSearchManager {
static let shared = SpotlightSearchManager()
private var lastFullIndexTime: Date?
private let minimumIndexInterval: TimeInterval = 300 // 5 minutes between full re-indexes
private init() {}
// MARK: - Permission and Setup
/// Check if Spotlight indexing is available
func isSpotlightAvailable() -> Bool {
return CSSearchableIndex.isIndexingAvailable()
}
/// Initialize Spotlight indexing with proper permissions
func initializeSpotlightIndexing() {
guard isSpotlightAvailable() else {
print("Spotlight indexing is not available on this device")
return
}
// Clear any existing index first, then reindex
clearAllIndexedLinks()
// Index all links after a short delay to ensure cleanup is complete
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.indexAllLinks()
}
}
// MARK: - Indexing
/// Index all links in Spotlight search
func indexAllLinks() {
// Rate limit full indexing to avoid overwhelming the system
if let lastIndex = lastFullIndexTime,
Date().timeIntervalSince(lastIndex) < minimumIndexInterval {
print("Skipping full index - too soon since last index")
return
}
let context = PersistenceController.shared.container.viewContext
let request: NSFetchRequest<Link> = Link.fetchRequest()
do {
let links = try context.fetch(request)
let searchableItems = links.compactMap { createSearchableItem(for: $0) }
CSSearchableIndex.default().indexSearchableItems(searchableItems) { error in
if let error = error {
print("Failed to index links: \(error.localizedDescription)")
} else {
print("Successfully indexed \(searchableItems.count) links")
self.lastFullIndexTime = Date()
}
}
} catch {
print("Failed to fetch links for indexing: \(error.localizedDescription)")
}
}
/// Index a single link
func indexLink(_ link: Link) {
guard let searchableItem = createSearchableItem(for: link) else { return }
CSSearchableIndex.default().indexSearchableItems([searchableItem]) { error in
if let error = error {
print("Failed to index link \(link.alias): \(error.localizedDescription)")
} else {
print("Successfully indexed link: \(link.alias)")
}
}
}
/// Remove a link from Spotlight index
func removeLink(withAlias alias: String) {
CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: [alias]) { error in
if let error = error {
print("Failed to remove link \(alias) from index: \(error.localizedDescription)")
} else {
print("Successfully removed link \(alias) from index")
}
}
}
/// Clear all indexed links
func clearAllIndexedLinks() {
CSSearchableIndex.default().deleteAllSearchableItems { error in
if let error = error {
print("Failed to clear all indexed links: \(error.localizedDescription)")
} else {
print("Successfully cleared all indexed links")
}
}
}
// MARK: - Debug and Testing
/// Get status of indexed items (for debugging)
func getIndexedItemsStatus(completion: @escaping (Int) -> Void) {
var query = CSSearchQuery(queryString: "contentType == 'public.url'", attributes: ["kMDItemTitle"])
query = CSSearchQuery(queryString: "*", attributes: nil)
var itemCount = 0
query.foundItemsHandler = { items in
itemCount += items.count
}
query.completionHandler = { error in
DispatchQueue.main.async {
if let error = error {
print("Error querying indexed items: \(error.localizedDescription)")
completion(0)
} else {
completion(itemCount)
}
}
}
query.start()
}
// MARK: - Private Methods
private func createSearchableItem(for link: Link) -> CSSearchableItem? {
let attributeSet = CSSearchableItemAttributeSet(contentType: UTType.url)
// Set basic attributes
attributeSet.title = "Heygo: \(link.alias)"
// Add URL if it's a link type
if link.linkTypeEnum == .link, let urlString = link.originalURL, let url = URL(string: urlString) {
attributeSet.contentURL = url
// Add URL as part of the content description for search
if let description = link.linkDescription, !description.isEmpty {
attributeSet.contentDescription = "\(description) - \(urlString)"
} else {
attributeSet.contentDescription = urlString
}
} else {
// For non-link types, just use the description
attributeSet.contentDescription = link.linkDescription ?? ""
}
// Add text content for custom/template types
if let text = link.text, !text.isEmpty {
attributeSet.textContent = text
}
// Add comprehensive keywords for better search
var keywords = [String]()
// Add the alias as primary keyword
keywords.append(link.alias)
// Add individual characters of alias for prefix matching
let aliasChars = Array(link.alias.lowercased())
for i in 1...aliasChars.count {
let prefix = String(aliasChars[0..<i])
keywords.append(prefix)
}
// Add tag names
let tagNames = link.sortedTags.map { $0.name }
keywords.append(contentsOf: tagNames)
// Add app identifier for filtering
keywords.append("heygo")
keywords.append("link")
// Add link type as keyword
keywords.append(link.linkTypeEnum.displayName.lowercased())
attributeSet.keywords = keywords
// Add click count as a ranking signal
attributeSet.rankingHint = NSNumber(value: link.clickCount)
// Add creation and modification dates
attributeSet.contentCreationDate = link.createdAt
attributeSet.contentModificationDate = link.updatedAt
// Set icon/thumbnail based on link type (using SF Symbols would be better, but text works)
switch link.linkTypeEnum {
case .link:
// For links, we could set a specific thumbnail or leave it to the system
break
case .custom:
// For custom content
break
case .template:
// For templates
break
}
// Create searchable item with unique identifier
let item = CSSearchableItem(uniqueIdentifier: link.alias, domainIdentifier: "heygo.links", attributeSet: attributeSet)
// Set expiration date (optional - remove after 30 days of no updates)
item.expirationDate = Calendar.current.date(byAdding: .day, value: 30, to: link.updatedAt)
return item
}
}
// MARK: - Search Result Handling
extension SpotlightSearchManager {
/// Handle when user selects a spotlight search result
static func handleSpotlightSearchSelection(userActivity: NSUserActivity) -> String? {
guard userActivity.activityType == CSSearchableItemActionType,
let uniqueIdentifier = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String else {
return nil
}
return uniqueIdentifier // This is the link alias
}
/// Open link and record click
static func openLinkFromSpotlight(alias: String, linkViewModel: LinkViewModel) {
// Find the link by alias
let context = PersistenceController.shared.container.viewContext
let request: NSFetchRequest<Link> = Link.fetchRequest()
request.predicate = NSPredicate(format: "alias == %@", alias)
request.fetchLimit = 1
do {
let links = try context.fetch(request)
guard let link = links.first else {
print("Link with alias '\(alias)' not found")
return
}
// Record the click
linkViewModel.recordClick(for: link)
// Handle different link types
switch link.linkTypeEnum {
case .link:
if let urlString = link.originalURL, let url = URL(string: urlString) {
// Open URL in default browser
if UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:]) { success in
if !success {
print("Failed to open URL: \(url)")
}
}
}
}
case .template:
// For template links, check if they have parameters
if let urlString = link.originalURL, link.hasParametersInURL() {
// If template has parameters, show the app to handle parameter input
NotificationCenter.default.post(
name: NSNotification.Name("ShowTemplateParameterInput"),
object: nil,
userInfo: ["link": link]
)
} else if let urlString = link.originalURL, let url = URL(string: urlString) {
// If no parameters, treat as regular link
if UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:]) { success in
if !success {
print("Failed to open template URL: \(url)")
}
}
}
}
case .custom:
// For custom types, show the content in the app
NotificationCenter.default.post(
name: NSNotification.Name("ShowLinkContent"),
object: nil,
userInfo: ["link": link]
)
}
} catch {
print("Failed to fetch link: \(error.localizedDescription)")
}
}
}