mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
292 lines
10 KiB
Swift
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)")
|
|
}
|
|
}
|
|
}
|