216 lines
8.5 KiB
Swift
216 lines
8.5 KiB
Swift
//
|
|
// 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<HistoryItemEntity> {
|
|
didSet { fetchedResultsController.delegate = self; performInitialFetch() }
|
|
}
|
|
|
|
fileprivate init(fetchedResultsController: NSFetchedResultsController<HistoryItemEntity>, 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<HistoryItem.ID>) {
|
|
items.forEach { identifier in
|
|
historyController.deleteItem(withIdentifier: identifier)
|
|
}
|
|
}
|
|
|
|
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
|
|
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<NSFetchRequestResult> = 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> = 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<HistoryItemEntity> {
|
|
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<HistoryItemEntity> {
|
|
let fetchRequest: NSFetchRequest<HistoryItemEntity> = 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
|
|
}
|
|
}
|