// // BrowserHistory.swift // App // // Created by James Magahern on 8/14/20. // import Foundation import CoreData import Combine class BrowserHistory { class ViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDelegate { @Published var searchQuery: String = "" @Published private(set) var historyItems: [HistoryItem] = [] fileprivate let historyController: BrowserHistory fileprivate var searchQueryObserver: AnyCancellable? = nil fileprivate var fetchedResultsController: NSFetchedResultsController { didSet { fetchedResultsController.delegate = self; performInitialFetch() } } fileprivate init(fetchedResultsController: NSFetchedResultsController, historyController: BrowserHistory) { self.fetchedResultsController = fetchedResultsController self.historyController = historyController super.init() searchQueryObserver = $searchQuery.sink { [unowned self] newValue in self.fetchedResultsController = historyController.fetchRequestController(forQuery: newValue) } } public func item(forIdentifier identifier: HistoryItem.ID) -> HistoryItem? { if let object = fetchedResultsController.managedObjectContext.object(with: identifier) as? HistoryItemEntity { return HistoryItem(entity: object) } return nil } public func deleteItems(_ items: Set) { items.forEach { identifier in historyController.deleteItem(withIdentifier: identifier) } } func controllerDidChangeContent(_ controller: NSFetchedResultsController) { if let objects = controller.fetchedObjects as? [HistoryItemEntity] { self.historyItems = objects.map { HistoryItem(entity: $0) } } } fileprivate func performInitialFetch() { try? fetchedResultsController.performFetch() if let objects = fetchedResultsController.fetchedObjects { self.historyItems = objects.map { HistoryItem(entity: $0) } } } } static public let shared = BrowserHistory() lazy fileprivate var persistentContainer: NSPersistentContainer = { let container = NSPersistentContainer(name: "History") container.loadPersistentStores { description, error in assert(error == nil) } return container }() public func didNavigate(toURL url: URL, title: String) { let dataContext = persistentContainer.viewContext let entity = HistoryItemEntity(context: dataContext) entity.url = url entity.lastVisited = Date() entity.title = title entity.host = url.host do { try dataContext.save() } catch { let nserror = error as NSError fatalError("Failed saving persistent entity to store: \(nserror), \(nserror.userInfo)") } UIMenuSystem.main.setNeedsRebuild() } public func viewModel(forFilterString filterString: String? = nil, limit: Int = 500) -> ViewModel { let resultsController = fetchRequestController(forQuery: filterString, limit: limit) return ViewModel(fetchedResultsController: resultsController, historyController: self) } public func historyItem(forIdentifier: HistoryItem.ID) -> HistoryItem? { if let entity = try? persistentContainer.viewContext.existingObject(with: forIdentifier) as? HistoryItemEntity { return HistoryItem(entity: entity) } return nil } public func deleteItem(withIdentifier identifier: HistoryItem.ID) { let dataContext = persistentContainer.viewContext if let object = try? dataContext.existingObject(with: identifier) { dataContext.delete(object) try? dataContext.save() } } public func clearAllHistory() { let dataContext = persistentContainer.viewContext let fetchRequest: NSFetchRequest = HistoryItemEntity.fetchRequest() let request = NSBatchDeleteRequest(fetchRequest: fetchRequest) do { try dataContext.execute(request) } catch { print("Error clearing history: \(error.localizedDescription)") } } public func allHistory(filteredBy filterString: String? = nil, limit: Int = 500) -> [HistoryItem] { let dataContext = persistentContainer.viewContext let fetchRequest = fetchRequest(forStringContaining: filterString, limit: limit) let entities: [HistoryItemEntity] = (try? dataContext.fetch(fetchRequest)) ?? [] return entities.map { (entity) -> HistoryItem in HistoryItem(entity: entity) } } public func visitedToplevelHistoryItems(matching: String) -> [HistoryItem] { let dataContext = persistentContainer.viewContext let fetchRequest: NSFetchRequest = HistoryItemEntity.fetchRequest() fetchRequest.predicate = NSPredicate(format: """ host CONTAINS[cd] %@ OR title CONTAINS[cd] %@ OR url ENDSWITH[cd] %@ """, matching, matching, matching) fetchRequest.fetchLimit = 200 fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "visitCount", ascending: false) ] let entities: [HistoryItemEntity] = (try? dataContext.fetch(fetchRequest)) ?? [] let allItems: [HistoryItem] = entities.map { HistoryItem(entity: $0) } var topLevelItems: [URL: (HistoryItem, Int)] = [:] for item in allItems { if item.url.pathComponents.count <= 2 { var score = 1 let topLevelURL = item.url.topLevelURL() var topLevelItem = topLevelItems[topLevelURL] ?? (item, 0) topLevelItem.0.url = topLevelURL if item.url.path == "/" || item.url.path == "" { score += 10 topLevelItem.0.title = item.title } topLevelItem.1 += score topLevelItems[topLevelURL] = topLevelItem if topLevelItems.count == 20 { break } } } return topLevelItems.values.map { return $0.0 }.sorted { (item1, item2) -> Bool in return topLevelItems[item1.url]!.1 > topLevelItems[item2.url]!.1 } } } extension BrowserHistory { fileprivate func fetchRequestController(forQuery query: String? = nil, limit: Int = 500) -> NSFetchedResultsController { let fetchRequest = fetchRequest(forStringContaining: query, limit: limit) return NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: persistentContainer.viewContext, sectionNameKeyPath: nil, cacheName: nil) } fileprivate func fetchRequest(forStringContaining filterString: String? = nil, limit: Int = 500) -> NSFetchRequest { let fetchRequest: NSFetchRequest = HistoryItemEntity.fetchRequest() fetchRequest.fetchLimit = limit fetchRequest.sortDescriptors = [ // Sort by date NSSortDescriptor(keyPath: \HistoryItemEntity.lastVisited, ascending: false) ] if let filterString, filterString.count > 0 { fetchRequest.predicate = NSPredicate(format: """ host CONTAINS[cd] %@ OR title CONTAINS[cd] %@ OR url CONTAINS[cd] %@ """, filterString, filterString, filterString) } return fetchRequest } } extension URL { public func topLevelURL() -> URL { if var components = URLComponents(url: self, resolvingAgainstBaseURL: false) { components.query = nil components.queryItems = nil components.fragment = nil if let url = components.url { return url } } return self } }